Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit fabd08f453d50ad2372560cd40a91360238a5848


Parents : e59e899
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-28T18:07:39-05:00

feat: improve portable storage handling and announce settings for page nodes

Also some cleanup and various fixes and additions to codebase

Changes

73 files changed, 2597 insertions(+), 198 deletions(-)

M .dockerignore +2 -3
M electron/main.js +17 -16

Diff

diff --git a/.dockerignore b/.dockerignore
index 15ef2d75..18dc228a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -136,12 +136,11 @@ bench_results.txt
# Test suites and reports (not used in container image build)
tests/
-# Desktop, CI, and packaging trees not used in Docker image
-electron/
+# CI and packaging trees not used in Docker image
+# electron/ and scripts/ci/ are required by Dockerfile.build (Electron packaging + build entry scripts)
.github/
packaging/
scripts/private/
-scripts/ci/
# Host-built micron WASM binaries (Docker/build fetch fresh copies)
meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm

diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index b0fc1468..909c147e 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -31,3 +31,16 @@ updates:
- sigstore/*
- pypa/*
- benchmark-action/*
+
+ - package-ecosystem: uv
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 10
+ versioning-strategy: increase-if-necessary
+
+ - package-ecosystem: gradle
+ directory: /android
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 10

diff --git a/.github/workflows/android-apk-tag.yml b/.github/workflows/android-apk-tag.yml
index b93fdb03..9227b852 100644
--- a/.github/workflows/android-apk-tag.yml
+++ b/.github/workflows/android-apk-tag.yml
@@ -25,7 +25,6 @@ on:
permissions:
contents: read
- actions: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml
index 4247ac39..e4d2fdf7 100644
--- a/.github/workflows/android-build.yml
+++ b/.github/workflows/android-build.yml
@@ -32,7 +32,6 @@ on:
permissions:
contents: read
- actions: write
concurrency:
group: android-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -66,8 +65,7 @@ jobs:
needs: frontend
timeout-minutes: 90
permissions:
- contents: write
- actions: write
+ contents: read
defaults:
run:
shell: bash

diff --git a/.github/workflows/android-emulator-smoke.yml b/.github/workflows/android-emulator-smoke.yml
index 2e31782d..1f022c98 100644
--- a/.github/workflows/android-emulator-smoke.yml
+++ b/.github/workflows/android-emulator-smoke.yml
@@ -28,7 +28,6 @@ on:
permissions:
contents: read
- actions: write
concurrency:
group: android-emulator-smoke-${{ github.ref }}
@@ -62,7 +61,6 @@ jobs:
timeout-minutes: 120
permissions:
contents: read
- actions: write
defaults:
run:
shell: bash

diff --git a/.github/workflows/build-linux-packages.yml b/.github/workflows/build-linux-packages.yml
index 2a5ed97d..c517f60f 100644
--- a/.github/workflows/build-linux-packages.yml
+++ b/.github/workflows/build-linux-packages.yml
@@ -64,7 +64,6 @@ jobs:
timeout-minutes: 120
permissions:
contents: read
- actions: write
env:
FRONTEND_ARTIFACT_NAME: ${{ needs.frontend.outputs.artifact_name }}
MESHCHATX_FRONTEND_PREBUILT: "1"

diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index 8dd97f73..34bccc8f 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -30,9 +30,7 @@ on:
workflow_dispatch:
permissions:
- contents: write
- actions: write
- id-token: write
+ contents: read
concurrency:
group: build-release-${{ github.workflow }}-${{ github.ref }}
@@ -100,7 +98,6 @@ jobs:
timeout-minutes: 120
permissions:
contents: read
- actions: write
env:
FRONTEND_ARTIFACT_NAME: ${{ needs.frontend.outputs.artifact_name }}
MESHCHATX_FRONTEND_PREBUILT: "1"
@@ -163,7 +160,6 @@ jobs:
hashes: ${{ steps.hash.outputs.hashes }}
permissions:
contents: read
- actions: write
id-token: write
steps:
- name: Checkout
@@ -218,7 +214,6 @@ jobs:
timeout-minutes: 90
permissions:
contents: read
- actions: write
env:
FRONTEND_ARTIFACT_NAME: ${{ needs.frontend.outputs.artifact_name }}
MESHCHATX_FRONTEND_PREBUILT: "1"
@@ -309,7 +304,6 @@ jobs:
needs: frontend
permissions:
contents: read
- actions: write
strategy:
fail-fast: false
matrix:

diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml
index 37170b92..7544517d 100644
--- a/.github/workflows/mutation.yml
+++ b/.github/workflows/mutation.yml
@@ -2,6 +2,7 @@
#
# Pinned first-party actions (bump tag and SHA together when upgrading):
# actions/checkout@v6.0.1 8e8c483db84b4bee98b60c0593521ed34d9990e8
+# actions/upload-artifact@v5.0.0 330a01c490aca151604b8cf639adc76d48f6c5d4
name: Mutation testing
@@ -72,7 +73,7 @@ jobs:
- name: Upload mutation reports
if: always()
- uses: actions/upload-artifact@v5.0.0
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
name: mutation-reports
path: |

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 886ed571..e43cc529 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,8 @@ All notable changes to this project will be documented in this file.
- **Translator (Landlock)**: On Linux, allow read/execute for user-local pipx CLIs (`~/.local/bin`, `~/.local/share/pipx`) and read-write for Argos Translate data under `~/.local/share/argos-translate`, so argospm language lists and local Argos translation work with the filesystem sandbox enabled.
- **Tests**: Landlock integration probes for subprocess spawn, translator Argos language listing, user-local CLI execution, and home write denial outside RW roots (tests/backend/test_landlock_integration_surfaces.py).
- **Windows desktop**: AppContainer sandboxing is opt-in (set `MESHCHAT_APPCONTAINER=1`) instead of on by default, to avoid extra launcher processes and heavy startup until the path is stable.
+- **Portable mode (Electron)**: The desktop app now honors `--data-dir`/`MESHCHAT_DATA_DIR` and `--storage-dir`/`--reticulum-config-dir` (flag or `MESHCHAT_STORAGE_DIR`/`MESHCHAT_RETICULUM_CONFIG_DIR` env var) on Linux and macOS, not just the Windows portable exe directory. Storage, Reticulum config, crash reports, and logs all resolve from the same root, so a removable drive or Tails persistence volume no longer touches `~/.reticulum-meshchatx` or `~/.reticulum`. Bots spawned for an identity now default their own RNS instance to the app's `reticulum_config_dir` instead of always falling back to `~/.reticulum` (`MESHCHAT_BOT_RETICULUM_CONFIG_DIR` still overrides this for a separate bot RNS instance). `resolve_log_dir()` also derives a logs directory from `MESHCHAT_DATA_DIR` when `MESHCHAT_STORAGE_DIR` is unset.
+- **Mesh Server (page nodes)**: A page node now re-announces itself on the mesh on a timer instead of only once when you press Announce, so peers do not lose the route after the initial announce expires. Each server has its own announce interval (default 15 minutes, clamped between 1 minute and 24 hours) and an automatic-announce toggle to disable mesh announcing for that server entirely. The mesh servers page shows when a server last announced and lets you edit the interval and toggle from the server's detail view.
## [4.8.1] - 2026-07-25

diff --git a/Dockerfile.build b/Dockerfile.build
index bbfa1e89..3cf70dfa 100644
--- a/Dockerfile.build
+++ b/Dockerfile.build
@@ -1,9 +1,16 @@
-# Reproducible native build (wheel, Linux AppImage/deb/rpm) in one image.
+# Reproducible native build (wheel, Linux AppImage/deb/rpm/apk) in one image.
# Default target builds everything; override with --build-arg MESHCHATX_BUILD_TARGETS=wheel|electron|all
+# Pick specific Linux package formats with --build-arg MESHCHATX_LINUX_FORMATS=appimage,deb,rpm,apk (default: all)
#
-# Build:
+# Build (all formats):
# docker build -f Dockerfile.build -t meshchatx-build:local .
#
+# Build only an AppImage and deb:
+# docker build -f Dockerfile.build \
+# --build-arg MESHCHATX_BUILD_TARGETS=electron \
+# --build-arg MESHCHATX_LINUX_FORMATS=appimage,deb \
+# -t meshchatx-build:local .
+#
# Copy artifacts to the host (example):
# cid=$(docker create meshchatx-build:local)
# docker cp "$cid":/artifacts ./meshchatx-artifacts
@@ -13,10 +20,13 @@ FROM python:3.14.4-slim-trixie
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
- MESHCHATX_BUILD_TARGETS=all
+ MESHCHATX_BUILD_TARGETS=all \
+ MESHCHATX_LINUX_FORMATS=all
ARG MESHCHATX_BUILD_TARGETS=all
ENV MESHCHATX_BUILD_TARGETS=${MESHCHATX_BUILD_TARGETS}
+ARG MESHCHATX_LINUX_FORMATS=all
+ENV MESHCHATX_LINUX_FORMATS=${MESHCHATX_LINUX_FORMATS}
WORKDIR /src
COPY . /src

diff --git a/Taskfile.yml b/Taskfile.yml
index f2b1092d..d4a3c57d 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -631,6 +631,12 @@ tasks:
cmds:
- "{{.NPM}} run dist:rpm"
+ dist:fe:apk:
+ desc: Build Alpine APK package
+ deps: [build:frontend]
+ cmds:
+ - "{{.NPM}} run dist:apk"
+
dist:fe:flatpak:
desc: Build Flatpak package
deps: [build:frontend]

diff --git a/docs/agents/conventions/core.md b/docs/agents/conventions/core.md
index 86ca080b..68f8f685 100644
--- a/docs/agents/conventions/core.md
+++ b/docs/agents/conventions/core.md
@@ -10,3 +10,5 @@
- Do not commit/push unless asked.
- User-visible UI strings: i18n keys. Action feedback: `ToastUtils`.
- Do not invent install/run flows when Taskfile already covers them.
+- Do not create markdown docs unless asked (except agent guidance under `docs/agents/` when requested).
+- Do not generate exploit PoCs, malware, or attack tooling.

diff --git a/docs/agents/conventions/reticulum-zen.md b/docs/agents/conventions/reticulum-zen.md
index 4c36e7b2..25e0c06f 100644
--- a/docs/agents/conventions/reticulum-zen.md
+++ b/docs/agents/conventions/reticulum-zen.md
@@ -48,11 +48,4 @@ Full checklist: `docs/agents/skills/reticulum-design-gates/SKILL.md`.
## Before you ship mesh-facing code
-Answer all of these. If any answer is wrong, redesign.
-
-1. Does this still work with no clearnet and only Reticulum interfaces up?
-2. Does it address a destination hash / aspect, not an IP or URL, for mesh peers?
-3. Can it tolerate minutes of delay or a missing path without corrupting state?
-4. Is the wire payload as small as the intent allows?
-5. Would a hostile transport node learn nothing useful beyond ciphertext and routing proofs?
-6. Is identity material and personal metadata default-redacted in any export or bug path?
+Run the full review checklist in `docs/agents/skills/reticulum-design-gates/SKILL.md` (clearnet-disabled operation, destination hash addressing, delay/missing-path tolerance, payload size, hostile-transport secrecy, identity/metadata redaction, no cross-identity leakage). If any gate fails, redesign.

diff --git a/docs/agents/conventions/tests.md b/docs/agents/conventions/tests.md
index 09ec72e4..3d43895c 100644
--- a/docs/agents/conventions/tests.md
+++ b/docs/agents/conventions/tests.md
@@ -15,22 +15,10 @@ Applies when editing `tests/**/*.{py,js}`.
## Oracle style (no soft fuzz)
Property and fuzz tests must assert an accept or reject outcome, not only that nothing crashed.
+Refuse bare `except Exception: pass`, `never_raises`-only tests, checking a result dict has an `"ok"` key without checking its value, and mocks that always succeed under a security oracle.
+Prefer an independent oracle (predict accept or reject from the input, then assert the code matches), jail oracles (resolved path stays under the allowed root), closed reason sets (`ValueError` message is one of the known machine reasons), or round-trip invariants for pure parsing.
-Refuse these patterns:
-
-- Bare `except Exception: pass` around the code under test
-- `never_raises` tests with no postcondition
-- Asserting only that a result dict has an `"ok"` key without checking True or False
-- Mocks that return the success path for every input under a security oracle
-
-Prefer:
-
-- Independent oracle: given input X, predict accept or reject, then assert the code matches
-- Jail oracles: on success, resolved path stays under the allowed root
-- Closed reason sets: on `ValueError`, the message is one of the known machine reasons
-- Round-trip or shape invariants when the API is pure parsing
-
-Full skill: `docs/agents/skills/test-oracles/SKILL.md`.
+Full guidance and examples: `docs/agents/skills/test-oracles/SKILL.md`.
Path jail filesystem features: `docs/agents/skills/path-jail-local-fs/SKILL.md` and `docs/agents/conventions/path-jail.md`.
Exploratory bug hunting: `docs/agents/skills/exploratory-testing/SKILL.md`.
@@ -62,7 +50,7 @@ UPDATE_HTTP_API_ROUTES=1 uv run pytest tests/backend/test_http_api_contract.py -
UPDATE_WS_MESSAGE_MANIFEST=1 uv run pytest tests/backend/test_ws_json_contracts.py -k manifest_matches_meshchat
```
-JSON response schemas stay in `tests/backend/` (`api_json_contract_schemas.py`, `http_api_response_schemas.py`, registry files). See `docs/agents/module-ownership.md`.
+JSON response schemas stay in `tests/backend/` (`api_json_contract_schemas.py`, `http_api_response_schemas.py`, registry files). Domain ownership for routes vs schemas: `docs/agents/module-ownership.md`. Extraction workflow: `docs/agents/skills/meshchat-orchestration-split/SKILL.md`.
## Extended Edge Case Tester (EECT) and Live Validation (LV)
@@ -71,16 +59,3 @@ JSON response schemas stay in `tests/backend/` (`api_json_contract_schemas.py`,
- Replay a failure with `MESHCHAT_EECT_SEED=<seed>` (printed on assert failure).
- Commands: `task test:eect`, `task test:lv:l0`, `MESHCHAT_LIVE_VALIDATION=1 task test:lv`.
- LV L2/L3 are opt-in (`MESHCHAT_LIVE_VALIDATION=1` or `MESHCHAT_LIVE_RETICULUM=1`). L0/L1 stay CI-safe.
-
-## HTTP and WebSocket contract scanners
-
-- HTTP route inventory: `tests/backend/http_api_contract_helpers.py` scans
- `meshchatx/meshchat.py` and all `meshchatx/src/backend/http/**/*.py`.
-- WS message inventory: `tests/backend/ws_contract_helpers.py` scans the same trees
- (plus `rns_link_manager.py` for `rns.link.*` broadcasts).
-- Refresh HTTP fixture only when routes intentionally change:
- `UPDATE_HTTP_API_ROUTES=1 uv run pytest tests/backend/test_http_api_contract.py -k meshchat_http_routes_match_fixture`
-- Refresh WS fixture only when message types intentionally change:
- `UPDATE_WS_MESSAGE_MANIFEST=1 uv run pytest tests/backend/test_ws_json_contracts.py -k manifest_matches_meshchat`
-- Domain ownership for routes vs schemas: `docs/agents/module-ownership.md`.
-- Extraction workflow: `docs/agents/skills/meshchat-orchestration-split/SKILL.md`.

diff --git a/docs/agents/module-ownership.md b/docs/agents/module-ownership.md
index 1aecc183..ce02fcf0 100644
--- a/docs/agents/module-ownership.md
+++ b/docs/agents/module-ownership.md
@@ -83,5 +83,13 @@ Frontend ownership contracts:
| HTTP JSON GET schemas | `http_api_response_schemas.py`, `http_api_response_registry.py` |
| Core status/auth/app_info | `api_json_contract_schemas.py` |
| WS message manifest | `tests/backend/fixtures/ws_message_manifest.json`, `ws_contract_helpers.py` (reads `meshchat.py`, `backend/http/**/*.py`, `lifecycle/**`, `rns_link_manager.py`) |
+| Schema version manifest | `tests/backend/fixtures/schema_versions/manifest.json`, `schema_versions_contract_helpers.py` (reads `DatabaseSchema.LATEST_VERSION`) |
+| Backend table itself | `tests/backend/fixtures/backend_module_ownership.json`, `test_module_ownership_contract.py` (parses this doc's Backend table and checks paths exist) |
Schema contracts stay in `tests/backend/`. Route and WS owners are production modules under `backend/http/`.
+
+All four fixtures above are generator-checked, not hand-edited: each owning test derives the
+expected structure from source and fails on drift, with an `UPDATE_HTTP_API_ROUTES=1` /
+`UPDATE_WS_MESSAGE_MANIFEST=1` / `UPDATE_SCHEMA_VERSIONS_MANIFEST=1` / `UPDATE_BACKEND_MODULE_OWNERSHIP=1`
+escape hatch to rewrite the fixture, matching the `UPDATE_FRONTEND_OWNERSHIP=1` pattern used by
+`tests/frontend/frontendOwnershipContract.test.js`.

diff --git a/docs/agents/overview.md b/docs/agents/overview.md
index 67379e0a..fc05489d 100644
--- a/docs/agents/overview.md
+++ b/docs/agents/overview.md
@@ -319,16 +319,7 @@ meshchatx --restore-db /path/to/backup.zip
## Agent hard rules
-1. No emojis in code, markdown, or docs you write for this repo.
-2. No TODO / FIXME noise comments.
-3. No emdashes or semicolons in comments or docs you write.
-4. No backticks in code comments. Prefer plain words or quoted identifiers.
-5. Do not create markdown docs unless asked (except agent guidance under `docs/agents/` when requested).
-6. Do not commit or push unless the user asks.
-7. Do not generate exploit PoCs, malware, or attack tooling.
-8. Prefer minimal diffs. Match nearby style.
-9. Do not invent install/run flows when Taskfile already covers them.
-10. Mesh-facing designs must pass Zen / architecture gates (`reticulum-zen.md` / `reticulum-design-gates`).
+Full list: `docs/agents/conventions/core.md` (mirrors the always-on editor rule). Mesh-facing designs must additionally pass the Zen / architecture gates (`reticulum-zen.md` / `reticulum-design-gates`).
## High-risk change checklist

diff --git a/docs/agents/skills/database-migrations-backups/SKILL.md b/docs/agents/skills/database-migrations-backups/SKILL.md
index 69d77d8e..06cb3401 100644
--- a/docs/agents/skills/database-migrations-backups/SKILL.md
+++ b/docs/agents/skills/database-migrations-backups/SKILL.md
@@ -47,11 +47,7 @@ This writes `tests/backend/fixtures/schema_versions/schema_v{N}.db` for latest,
## Two restore operations
-| Goal | API / CLI | Artifact |
-| ---------------------------------- | ----------------------------------------------- | --------------------------- |
-| Private key only | `POST /api/v1/identity/restore` | identity key bytes / `.bin` |
-| History + settings + identity tree | `POST /api/v1/database/restore`, `--restore-db` | `.zip` |
-
+Private key only vs full history/settings/identity tree restore are different operations with different APIs. Full table: `docs/agents/overview.md` (Identity key restore vs database restore section).
Details for pickers and tutorial copy: `identity-restore`.
## Key files

diff --git a/docs/agents/skills/identity-restore/SKILL.md b/docs/agents/skills/identity-restore/SKILL.md
index 49624f3b..8e8ce3ae 100644
--- a/docs/agents/skills/identity-restore/SKILL.md
+++ b/docs/agents/skills/identity-restore/SKILL.md
@@ -6,11 +6,7 @@ Identity key import vs database zip restore, tutorial and Android pickers.
## Two different restores
-| Goal | UI | API / artifact |
-| -------------------- | ---------------------------------- | ---------------------------------------- |
-| Identity private key | Tutorial step 2, Identities import | `POST /api/v1/identity/restore` |
-| LXMF + settings + DB | About → Restore from File | `POST /api/v1/database/restore` (`.zip`) |
-
+Full table of goal / UI / API for both restore paths: `docs/agents/overview.md` (Identity key restore vs database restore section).
Never imply identity-key import restores message history.
## Guards checklist

diff --git a/docs/agents/skills/landlock-sqlite/SKILL.md b/docs/agents/skills/landlock-sqlite/SKILL.md
index 101939d1..a45ddbe6 100644
--- a/docs/agents/skills/landlock-sqlite/SKILL.md
+++ b/docs/agents/skills/landlock-sqlite/SKILL.md
@@ -51,7 +51,6 @@ Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store,
- Module: `meshchatx/src/backend/appcontainer_sandbox.py`
- Launcher: `meshchatx/src/backend/appcontainer_launcher.py` via `--meshchatx-run-module`
- Electron win32 spawn uses the launcher only when `MESHCHAT_APPCONTAINER=1`
-- Docs: `meshchatx-docs/en/platform-guides/windows-sandbox.md`
## Verification

diff --git a/docs/agents/skills/test-loop/SKILL.md b/docs/agents/skills/test-loop/SKILL.md
index bfc782d7..2056f3fa 100644
--- a/docs/agents/skills/test-loop/SKILL.md
+++ b/docs/agents/skills/test-loop/SKILL.md
@@ -48,8 +48,4 @@ pnpm exec vitest run tests/frontend/<related>.test.js
## After identity / Landlock edits
-Run the matching skill's verification section before claiming done.
-
-```bash
-uv run pytest tests/backend/test_landlock_sandbox.py tests/backend/test_landlock_integration_surfaces.py tests/backend/test_sqlite_landlock_temp_store.py -q
-```
+Run the matching skill's verification section before claiming done: `landlock-sqlite` for the full Landlock/SQLite/AppContainer command set, `identity-switch-teardown` or `identity-restore` for identity flows.

diff --git a/docs/agents/skills/vue-mega-page-split/SKILL.md b/docs/agents/skills/vue-mega-page-split/SKILL.md
index 1748d9bf..00b24653 100644
--- a/docs/agents/skills/vue-mega-page-split/SKILL.md
+++ b/docs/agents/skills/vue-mega-page-split/SKILL.md
@@ -32,13 +32,7 @@ Also read:
## Placement
-| Kind | Put it here | Example |
-| --------------------------- | --------------------------------------------------- | ----------------------------------- |
-| Page-private panel or UI | `components/<feature>/internal/*.vue` | MapSearchBar |
-| Settings chunk | `components/settings/sections/*SettingsSection.vue` | TelephonySettingsSection |
-| Pure logic | colocated `*.js` or `js/<feature>/` | clusterUtils, settingsConfigService |
-| Cross-feature primitive | root `components/` or `components/forms/` | ConfirmDialog, Toggle |
-| Nav, tools, commands wiring | `js/registries/` only | never grow App.vue for discovery |
+Placement table (page-private panels, settings chunks, pure logic, cross-feature primitives, nav/tools wiring): `docs/agents/module-ownership.md` (Frontend mega-pages section). Do not invent folders outside that table.
## Pre-extract inventory (mandatory)

diff --git a/docs/en/installation.md b/docs/en/installation.md
index eac2d5c6..188c2cda 100644
--- a/docs/en/installation.md
+++ b/docs/en/installation.md
@@ -177,7 +177,14 @@ meshchatx --headless \
--reticulum-config-dir="$PERSIST/.reticulum"
```
-On Windows portable Electron builds, storage and Reticulum config default next to the `.exe` when `PORTABLE_EXECUTABLE_DIR` is set. On Linux and macOS desktop builds, Electron still defaults to `~/.reticulum-meshchatx` and `~/.reticulum` unless you pass the flags above (or set `MESHCHAT_DATA_DIR` / `MESHCHAT_STORAGE_DIR` / `MESHCHAT_RETICULUM_CONFIG_DIR` in the environment before launch).
+The Electron desktop app (AppImage, portable exe, macOS bundle) honors the same `--data-dir` / `--storage-dir` / `--reticulum-config-dir` flags (or the matching `MESHCHAT_DATA_DIR` / `MESHCHAT_STORAGE_DIR` / `MESHCHAT_RETICULUM_CONFIG_DIR` environment variables) on every platform, not just Windows:
+
+```bash
+export PERSIST="/media/amnesia/Persistent/meshchatx"
+./MeshChatX-x86_64.AppImage --data-dir="$PERSIST"
+```
+
+On Windows portable exe builds, storage and Reticulum config also default next to the `.exe` when `PORTABLE_EXECUTABLE_DIR` is set (used by the portable target automatically), without needing any flags.
## Reticulum manual bundle

diff --git a/electron/main.js b/electron/main.js
index 17ee394e..d7d33a90 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -21,6 +21,7 @@ const { createBackendProcessManager } = require("./backendProcess");
const { getCrashRecoveryInfo } = require("./offlineRecovery");
const {
getUserProvidedArguments,
+ resolvePortableStorageRoots,
formatRenderProcessGoneDetails,
isLocalBackendUrl,
shouldOpenInElectronWindow,
@@ -685,26 +686,26 @@ function log(message) {
mainWindow.webContents.send("log", message);
}
-function getDefaultStorageDir() {
- const portableExecutableDir = process.env.PORTABLE_EXECUTABLE_DIR;
- if (process.platform === "win32" && portableExecutableDir != null) {
- return path.join(portableExecutableDir, ".reticulum-meshchatx");
- }
+// Resolves storage and Reticulum config roots the same way on every platform:
+// explicit --storage-dir/--reticulum-config-dir (flag or env) first, then
+// --data-dir/MESHCHAT_DATA_DIR (portable root), then a Windows portable exe
+// directory, then the user home directory. See mainHelpers.resolvePortableStorageRoots.
+function getPortableStorageRoots() {
+ return resolvePortableStorageRoots({
+ argv: process.argv,
+ env: process.env,
+ homeDir: app.getPath("home"),
+ isWindows: process.platform === "win32",
+ portableExecutableDir: process.env.PORTABLE_EXECUTABLE_DIR,
+ });
+}
- return path.join(app.getPath("home"), ".reticulum-meshchatx");
+function getDefaultStorageDir() {
+ return getPortableStorageRoots().storageDir;
}
function getDefaultReticulumConfigDir() {
- // if we are running a windows portable exe, we want to use .reticulum in the portable exe dir
- // e.g if we launch "E:\Some\Path\MeshChat.exe" we want to use "E:\Some\Path\.reticulum"
- const portableExecutableDir = process.env.PORTABLE_EXECUTABLE_DIR;
- if (process.platform === "win32" && portableExecutableDir != null) {
- return path.join(portableExecutableDir, ".reticulum");
- }
-
- // otherwise, we will fall back to using the .reticulum folder in the users home directory
- // e.g: ~/.reticulum
- return path.join(app.getPath("home"), ".reticulum");
+ return getPortableStorageRoots().reticulumConfigDir;
}
function getAppIconPath() {

diff --git a/electron/mainHelpers.js b/electron/mainHelpers.js
index 2064268a..d6651689 100644
--- a/electron/mainHelpers.js
+++ b/electron/mainHelpers.js
@@ -1,5 +1,7 @@
"use strict";
+const path = require("node:path");
+
const IGNORED_CLI_ARGUMENTS = new Set(["--no-sandbox", "--ozone-platform-hint=auto"]);
/**
@@ -12,6 +14,92 @@ function getUserProvidedArguments(argv) {
return list.slice(1).filter((arg) => !IGNORED_CLI_ARGUMENTS.has(arg));
}
+/**
+ * Read a "--flag value" pair from argv. Does not match "--flag=value" forms.
+ * @param {string[]} argv
+ * @param {string} flagName
+ * @returns {string | null}
+ */
+function parseArgvFlag(argv, flagName) {
+ const list = Array.isArray(argv) ? argv : [];
+ const idx = list.indexOf(flagName);
+ if (idx === -1 || idx + 1 >= list.length) {
+ return null;
+ }
+ const value = list[idx + 1];
+ if (!value || value.startsWith("--")) {
+ return null;
+ }
+ return value;
+}
+
+function firstNonEmpty(...values) {
+ for (const value of values) {
+ if (typeof value === "string" && value.trim()) {
+ return value.trim();
+ }
+ }
+ return null;
+}
+
+/**
+ * Resolve the storage and Reticulum config roots the backend will use.
+ *
+ * Mirrors meshchatx.src.path_utils.resolve_meshchat_data_roots precedence so
+ * Electron and the CLI agree on portable-mode layout:
+ * 1. Explicit --storage-dir / --reticulum-config-dir (argv flag or env var)
+ * 2. --data-dir / MESHCHAT_DATA_DIR (fills <root>/storage, <root>/.reticulum)
+ * 3. Windows portable executable directory (PORTABLE_EXECUTABLE_DIR)
+ * 4. The user home directory
+ *
+ * @param {object} ctx
+ * @param {string[]} ctx.argv Electron process.argv
+ * @param {Record<string, string | undefined>} ctx.env
+ * @param {string} ctx.homeDir
+ * @param {boolean} ctx.isWindows
+ * @param {string | null | undefined} [ctx.portableExecutableDir]
+ * @returns {{ storageDir: string, reticulumConfigDir: string }}
+ */
+function resolvePortableStorageRoots(ctx) {
+ const { argv, env, homeDir, isWindows, portableExecutableDir } = ctx;
+ const userArgv = getUserProvidedArguments(argv);
+
+ let storageDir = firstNonEmpty(parseArgvFlag(userArgv, "--storage-dir"), env && env.MESHCHAT_STORAGE_DIR);
+ let reticulumConfigDir = firstNonEmpty(
+ parseArgvFlag(userArgv, "--reticulum-config-dir"),
+ env && env.MESHCHAT_RETICULUM_CONFIG_DIR
+ );
+
+ const dataDir = firstNonEmpty(parseArgvFlag(userArgv, "--data-dir"), env && env.MESHCHAT_DATA_DIR);
+ if (dataDir) {
+ const root = path.resolve(dataDir);
+ if (!storageDir) {
+ storageDir = path.join(root, "storage");
+ }
+ if (!reticulumConfigDir) {
+ reticulumConfigDir = path.join(root, ".reticulum");
+ }
+ }
+
+ if (isWindows && portableExecutableDir) {
+ if (!storageDir) {
+ storageDir = path.join(portableExecutableDir, ".reticulum-meshchatx");
+ }
+ if (!reticulumConfigDir) {
+ reticulumConfigDir = path.join(portableExecutableDir, ".reticulum");
+ }
+ }
+
+ if (!storageDir) {
+ storageDir = path.join(homeDir, ".reticulum-meshchatx");
+ }
+ if (!reticulumConfigDir) {
+ reticulumConfigDir = path.join(homeDir, ".reticulum");
+ }
+
+ return { storageDir, reticulumConfigDir };
+}
+
/**
* @param {unknown} details Electron render-process-gone details
* @returns {string}
@@ -87,6 +175,8 @@ function shouldAllowInWindowNavigation(url) {
module.exports = {
getUserProvidedArguments,
+ parseArgvFlag,
+ resolvePortableStorageRoots,
formatRenderProcessGoneDetails,
isLocalBackendUrl,
shouldOpenInElectronWindow,

diff --git a/electron/shellPathGuard.js b/electron/shellPathGuard.js
index 45008919..291eb631 100644
--- a/electron/shellPathGuard.js
+++ b/electron/shellPathGuard.js
@@ -3,18 +3,7 @@
const fs = require("fs");
const path = require("node:path");
-function parseArgvFlag(argv, flagName) {
- const list = Array.isArray(argv) ? argv : [];
- const idx = list.indexOf(flagName);
- if (idx === -1 || idx + 1 >= list.length) {
- return null;
- }
- const v = list[idx + 1];
- if (!v || v.startsWith("--")) {
- return null;
- }
- return v;
-}
+const { parseArgvFlag } = require("./mainHelpers");
function resolveDirForPrefixCheck(dirPath) {
try {

diff --git a/meshchatx.rsm b/meshchatx.rsm
index db3c6ce1..237c3abc 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index afcb7bed..1ee7ec6d 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -652,7 +652,10 @@ class ReticulumMeshChat:
self.download_id_counter = 0
self.identity_manager = IdentityManager(self.storage_dir, identity_file_path)
- self.page_node_manager = PageNodeManager(self.storage_dir)
+ self.page_node_manager = PageNodeManager(
+ self.storage_dir,
+ on_announce=self._register_local_page_node_announce,
+ )
self.plugin_manager = PluginManager(self.storage_dir, app=self)
from meshchatx.src.backend.bug_report_manager import BugReportManager
@@ -1529,6 +1532,38 @@ class ReticulumMeshChat:
self._schedule_process_restart()
return result
+ def auto_recover_database(self, *, relaunch: bool = True) -> dict:
+ from meshchatx.src.backend.database.auto_recover import (
+ run_auto_database_recover,
+ )
+ from meshchatx.src.backend.database.schema import DatabaseSchema
+
+ storage = self.storage_path
+ if not storage:
+ msg = "Storage path is unknown"
+ raise RuntimeError(msg)
+
+ def restore_fn(path: str) -> dict:
+ return self.restore_database(path, relaunch=relaunch)
+
+ def sqlite_recover_fn() -> dict:
+ path = self.database_path
+ if not path:
+ msg = "Database path is unknown"
+ raise RuntimeError(msg)
+ from meshchatx.src.backend.database import Database
+
+ db = Database(path)
+ return db.run_database_recovery()
+
+ return run_auto_database_recover(
+ storage,
+ self.database_path,
+ DatabaseSchema.LATEST_VERSION,
+ restore_fn,
+ sqlite_recover_fn=sqlite_recover_fn,
+ )
+
def _resolve_database_restore_path(self, path: str) -> str | None:
"""Resolve a restore zip under identity snapshots or database-backups only."""
if not isinstance(path, str) or not path or "\x00" in path:

diff --git a/meshchatx/src/backend/bot_handler.py b/meshchatx/src/backend/bot_handler.py
index 2d7ad776..8c1d9926 100644
--- a/meshchatx/src/backend/bot_handler.py
+++ b/meshchatx/src/backend/bot_handler.py
@@ -25,12 +25,29 @@ _MESHCHATX_RUN_MODULE_FLAG = "--meshchatx-run-module"
class BotHandler:
- def __init__(self, identity_path, config_manager=None):
+ def __init__(
+ self,
+ identity_path,
+ config_manager=None,
+ default_reticulum_config_dir=None,
+ ):
+ """Manage LXMFy bot subprocesses for one identity.
+
+ default_reticulum_config_dir should be the running app's own
+ reticulum_config_dir so bots stay inside a custom --data-dir /
+ --reticulum-config-dir root instead of leaking to the home
+ directory. MESHCHAT_BOT_RETICULUM_CONFIG_DIR still overrides both,
+ for callers that intentionally want bots on a separate RNS instance.
+ """
self.identity_path = os.path.abspath(identity_path)
self.config_manager = config_manager
+ fallback_reticulum_dir = default_reticulum_config_dir or "~/.reticulum"
self.bot_reticulum_config_dir = os.path.abspath(
os.path.expanduser(
- os.environ.get("MESHCHAT_BOT_RETICULUM_CONFIG_DIR", "~/.reticulum"),
+ os.environ.get(
+ "MESHCHAT_BOT_RETICULUM_CONFIG_DIR",
+ fallback_reticulum_dir,
+ ),
),
)
self.bots_dir = os.path.join(self.identity_path, "bots")

diff --git a/meshchatx/src/backend/database/auto_recover.py b/meshchatx/src/backend/database/auto_recover.py
new file mode 100644
index 00000000..b97f1f4f
--- /dev/null
+++ b/meshchatx/src/backend/database/auto_recover.py
@@ -0,0 +1,239 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Pick a compatible on-disk backup and run SQLite recovery when backups are unavailable."""
+
+from __future__ import annotations
+
+import os
+import re
+import tempfile
+import zipfile
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from typing import Any
+
+from meshchatx.src.backend.database import Database, PRE_MIGRATE_BACKUP_PREFIX
+from meshchatx.src.backend.database.provider import DatabaseProvider
+
+_SUSPICIOUS_RE = re.compile(r"SUSPICIOUS", re.I)
+_PRE_MIGRATE_VERSION_RE = re.compile(
+ rf"{re.escape(PRE_MIGRATE_BACKUP_PREFIX)}v(\d+)-to-v(\d+)",
+ re.I,
+)
+
+
+@dataclass(frozen=True)
+class RecoveryBackupCandidate:
+ name: str
+ path: str
+ created_at: str
+ kind: str
+ suspicious: bool
+
+
+def list_recovery_backup_candidates(storage_path: str) -> list[RecoveryBackupCandidate]:
+ if not storage_path:
+ return []
+ entries: list[RecoveryBackupCandidate] = []
+ for kind, subdir in (("auto", "database-backups"), ("snapshot", "snapshots")):
+ directory = os.path.join(storage_path, subdir)
+ if not os.path.isdir(directory):
+ continue
+ for name in os.listdir(directory):
+ if not name.endswith(".zip"):
+ continue
+ full_path = os.path.join(directory, name)
+ if not os.path.isfile(full_path):
+ continue
+ stats = os.stat(full_path)
+ entries.append(
+ RecoveryBackupCandidate(
+ name=name,
+ path=full_path,
+ created_at=datetime.fromtimestamp(stats.st_mtime, UTC).isoformat(),
+ kind=kind,
+ suspicious=bool(_SUSPICIOUS_RE.search(name)),
+ ),
+ )
+ return sorted(entries, key=lambda row: row.created_at, reverse=True)
+
+
+def read_schema_version_from_db_path(db_path: str) -> int | None:
+ provider = DatabaseProvider(db_path)
+ try:
+ row = provider.fetchone(
+ "SELECT value FROM config WHERE key = ?",
+ ("database_version",),
+ )
+ if not row:
+ return 0
+ return int(row["value"])
+ except (TypeError, ValueError):
+ return None
+ except Exception:
+ return None
+ finally:
+ provider.close_all()
+
+
+def infer_version_hint_from_backup_name(name: str) -> int | None:
+ match = _PRE_MIGRATE_VERSION_RE.search(name)
+ if not match:
+ return None
+ try:
+ return int(match.group(2))
+ except ValueError:
+ return None
+
+
+def schema_version_restorable(version: int | None, latest_schema_version: int) -> bool:
+ if version is None:
+ return False
+ return 0 <= version <= latest_schema_version
+
+
+def _quick_check_label(provider: DatabaseProvider) -> str:
+ rows = provider.quick_check()
+ if not rows:
+ return "unknown"
+ first = rows[0]
+ if isinstance(first, dict):
+ return str(next(iter(first.values())))
+ return str(first[0])
+
+
+def _find_primary_db_member(zf: zipfile.ZipFile) -> str | None:
+ members: list[str] = []
+ for member in zf.namelist():
+ if member.endswith("/"):
+ continue
+ base = os.path.basename(member)
+ if base.endswith(".db") and not base.endswith(("-wal", "-shm")):
+ members.append(member)
+ if not members:
+ return None
+ return sorted(members, key=lambda name: name.count("/"))[0]
+
+
+def probe_backup_zip(zip_path: str) -> dict[str, Any]:
+ if not os.path.isfile(zip_path):
+ return {"version": None, "quick_check": None, "error": "backup file not found"}
+ if not zipfile.is_zipfile(zip_path):
+ return {"version": None, "quick_check": None, "error": "not a zip backup"}
+
+ with tempfile.TemporaryDirectory(prefix="meshchatx-backup-probe-") as tmp:
+ try:
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ member = _find_primary_db_member(zf)
+ if not member:
+ return {
+ "version": None,
+ "quick_check": None,
+ "error": "no database file in backup",
+ }
+ Database._safe_zip_extract_member(zf, member, tmp)
+ db_path = os.path.join(tmp, os.path.basename(member))
+ if not os.path.isfile(db_path):
+ found: str | None = None
+ for root, _, files in os.walk(tmp):
+ for name in files:
+ if name.endswith(".db") and not name.endswith(("-wal", "-shm")):
+ found = os.path.join(root, name)
+ break
+ if found:
+ break
+ db_path = found
+ if not db_path or not os.path.isfile(db_path):
+ return {
+ "version": None,
+ "quick_check": None,
+ "error": "extracted database missing",
+ }
+ version = read_schema_version_from_db_path(db_path)
+ provider = DatabaseProvider(db_path)
+ try:
+ quick_check = _quick_check_label(provider)
+ finally:
+ provider.close_all()
+ return {"version": version, "quick_check": quick_check, "error": None}
+ except Exception as exc:
+ return {"version": None, "quick_check": None, "error": str(exc)}
+
+
+def pick_compatible_backup(
+ storage_path: str,
+ latest_schema_version: int,
+) -> dict[str, Any] | None:
+ candidates = list_recovery_backup_candidates(storage_path)
+ healthy = [row for row in candidates if not row.suspicious]
+ ordered = healthy if healthy else list(candidates)
+
+ for candidate in ordered:
+ probe = probe_backup_zip(candidate.path)
+ version = probe.get("version")
+ if version is None:
+ version = infer_version_hint_from_backup_name(candidate.name)
+ if not schema_version_restorable(version, latest_schema_version):
+ continue
+ quick_check = probe.get("quick_check")
+ if quick_check is not None and quick_check != "ok":
+ continue
+ if probe.get("error") and version is None:
+ continue
+ return {
+ "name": candidate.name,
+ "path": candidate.path,
+ "kind": candidate.kind,
+ "database_version": version,
+ "quick_check": quick_check,
+ "suspicious": candidate.suspicious,
+ }
+ return None
+
+
+def run_auto_database_recover(
+ storage_path: str,
+ db_path: str | None,
+ latest_schema_version: int,
+ restore_fn: Callable[[str], dict],
+ sqlite_recover_fn: Callable[[], dict] | None = None,
+) -> dict[str, Any]:
+ picked = pick_compatible_backup(storage_path, latest_schema_version)
+ if picked:
+ restore_result = restore_fn(picked["path"])
+ return {
+ "strategy": "restore_backup",
+ "message": f"Restored database from {picked['name']}",
+ "backup": picked,
+ "requires_relaunch": True,
+ "restore_result": restore_result,
+ }
+
+ if sqlite_recover_fn is not None and db_path and os.path.isfile(db_path):
+ try:
+ recovery = sqlite_recover_fn()
+ return {
+ "strategy": "sqlite_recovery",
+ "message": "SQLite recovery routine completed on the live database",
+ "requires_relaunch": False,
+ "database": recovery,
+ }
+ except Exception as exc:
+ return {
+ "strategy": "none",
+ "message": (
+ "No compatible automatic backup was found and SQLite recovery failed"
+ ),
+ "requires_relaunch": False,
+ "error": str(exc),
+ }
+
+ return {
+ "strategy": "none",
+ "message": (
+ "No compatible automatic backup was found. Use Restore from file or "
+ "pick a snapshot manually."
+ ),
+ "requires_relaunch": False,
+ }

diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py
index 72eb2571..72d8ebf8 100644
--- a/meshchatx/src/backend/database/schema.py
+++ b/meshchatx/src/backend/database/schema.py
@@ -31,7 +31,7 @@ def _validate_identifier(name: str, label: str = "identifier") -> str:
class DatabaseSchema:
- LATEST_VERSION = 53
+ LATEST_VERSION = 54
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -1664,3 +1664,8 @@ class DatabaseSchema:
"CREATE INDEX IF NOT EXISTS idx_rrc_room_keys_hub "
"ON rrc_room_keys(hub_hash, dest_name)",
)
+
+ if current_version < 54 and target_version >= 54:
+ # v54: version alignment for databases already stamped at 54 without
+ # additional structural changes beyond v53.
+ pass

diff --git a/meshchatx/src/backend/http/routes/database.py b/meshchatx/src/backend/http/routes/database.py
index 260495be..a09ddcff 100644
--- a/meshchatx/src/backend/http/routes/database.py
+++ b/meshchatx/src/backend/http/routes/database.py
@@ -264,7 +264,17 @@ def register_database_routes(routes, app):
try:
limit = int(request.query.get("limit", 100))
offset = int(request.query.get("offset", 0))
- sorted_backups = app.database.list_auto_backups(app.storage_path)
+ storage_path = app.storage_path
+ if app.database is not None:
+ sorted_backups = app.database.list_auto_backups(storage_path)
+ else:
+ from meshchatx.src.backend.database import Database
+
+ db_path = app.database_path
+ if not db_path:
+ sorted_backups = []
+ else:
+ sorted_backups = Database(db_path).list_auto_backups(storage_path)
total = len(sorted_backups)
paginated_backups = sorted_backups[offset : offset + limit]
return web.json_response(
@@ -405,6 +415,39 @@ def register_database_routes(routes, app):
status=500,
)
+ @routes.post("/api/v1/database/auto-recover")
+ async def database_auto_recover(request):
+ try:
+ try:
+ data = await request.json()
+ except Exception:
+ data = {}
+ if not isinstance(data, dict):
+ data = {}
+ relaunch = bool(data.get("relaunch", True))
+ result = app.auto_recover_database(relaunch=relaunch)
+ status = 200 if result.get("strategy") != "none" else 500
+ return web.json_response(
+ {
+ "message": result.get("message"),
+ "strategy": result.get("strategy"),
+ "requires_relaunch": bool(result.get("requires_relaunch")),
+ "backup": result.get("backup"),
+ "database": result.get("database"),
+ "restore_result": result.get("restore_result"),
+ "error": result.get("error"),
+ },
+ status=status,
+ )
+ except Exception as e:
+ return web.json_response(
+ {
+ "message": f"Auto recovery failed: {e!s}",
+ "strategy": "none",
+ },
+ status=500,
+ )
+
@routes.post("/api/v1/database/backup")
async def database_backup(request):
try:

diff --git a/meshchatx/src/backend/http/routes/page_nodes.py b/meshchatx/src/backend/http/routes/page_nodes.py
index e769e071..5abddd5b 100644
--- a/meshchatx/src/backend/http/routes/page_nodes.py
+++ b/meshchatx/src/backend/http/routes/page_nodes.py
@@ -146,7 +146,13 @@ def register_page_nodes_routes(routes, app):
name = data.get("name", "").strip()
if not name:
return web.json_response({"message": "Name is required"}, status=400)
- node = app.page_node_manager.create_node(name)
+ announce_enabled = bool(data.get("announce_enabled", True))
+ announce_interval_seconds = data.get("announce_interval_seconds")
+ node = app.page_node_manager.create_node(
+ name,
+ announce_enabled=announce_enabled,
+ announce_interval_seconds=announce_interval_seconds,
+ )
return web.json_response(node.get_status())
@routes.get("/api/v1/page-nodes/{node_id}")
@@ -216,6 +222,34 @@ def register_page_nodes_routes(routes, app):
except KeyError:
return web.json_response({"message": "Node not found"}, status=404)
+ @routes.patch("/api/v1/page-nodes/{node_id}/announce-settings")
+ async def page_nodes_update_announce_settings(request):
+ node_id = request.match_info["node_id"]
+ try:
+ data = await request.json()
+ except Exception as e:
+ return web.json_response(
+ {"message": f"Invalid request body: {e}"},
+ status=400,
+ )
+ announce_enabled = (
+ data.get("announce_enabled") if "announce_enabled" in data else None
+ )
+ announce_interval_seconds = (
+ data.get("announce_interval_seconds")
+ if "announce_interval_seconds" in data
+ else None
+ )
+ try:
+ node = app.page_node_manager.set_announce_settings(
+ node_id,
+ announce_enabled=announce_enabled,
+ announce_interval_seconds=announce_interval_seconds,
+ )
+ return web.json_response(node.get_status())
+ except KeyError:
+ return web.json_response({"message": "Node not found"}, status=404)
+
@routes.get("/api/v1/page-nodes/{node_id}/pages")
async def page_nodes_list_pages(request):
node_id = request.match_info["node_id"]

diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index 4d3aac7d..d86ebd75 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -583,6 +583,11 @@ class IdentityContext:
self.bot_handler = BotHandler(
identity_path=self.storage_path,
config_manager=self.config,
+ default_reticulum_config_dir=getattr(
+ self.app,
+ "reticulum_config_dir",
+ None,
+ ),
)
try:
self.bot_handler.restore_enabled_bots()

diff --git a/meshchatx/src/backend/page_node.py b/meshchatx/src/backend/page_node.py
index 3abe740e..b4632c39 100644
--- a/meshchatx/src/backend/page_node.py
+++ b/meshchatx/src/backend/page_node.py
@@ -14,8 +14,10 @@ to fetch a page, or /file/name for files.
Supported page filename extensions are .mu, .md, .txt, and .html.
"""
+import contextlib
import json
import os
+import threading
import time
import RNS
@@ -26,6 +28,29 @@ DEFAULT_INDEX = "index.mu"
ALLOWED_PAGE_EXTENSIONS = frozenset({".mu", ".md", ".txt", ".html"})
+DEFAULT_ANNOUNCE_INTERVAL_SECONDS = 900
+MIN_ANNOUNCE_INTERVAL_SECONDS = 60
+MAX_ANNOUNCE_INTERVAL_SECONDS = 86400
+
+
+def normalize_announce_interval_seconds(
+ value,
+ default=DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+):
+ """Clamp an announce interval to the supported range, or 0 to disable periodic announces."""
+ if value is None:
+ return int(default)
+ try:
+ seconds = int(value)
+ except (TypeError, ValueError):
+ return int(default)
+ if seconds <= 0:
+ return 0
+ return max(
+ MIN_ANNOUNCE_INTERVAL_SECONDS,
+ min(MAX_ANNOUNCE_INTERVAL_SECONDS, seconds),
+ )
+
def normalize_page_filename(name: str) -> str:
"""Return a safe basename with an allowed extension. Unknown extensions raise ValueError."""
@@ -74,7 +99,17 @@ def _reject_name_component_too_long(parent_dir: str, component: str) -> None:
class PageNode:
"""A single page-serving node on the Reticulum mesh."""
- def __init__(self, node_id, name, base_dir, identity=None, identity_path=None):
+ def __init__(
+ self,
+ node_id,
+ name,
+ base_dir,
+ identity=None,
+ identity_path=None,
+ announce_enabled=True,
+ announce_interval_seconds=DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+ on_announce=None,
+ ):
self.node_id = node_id
self.name = name
self.base_dir = base_dir
@@ -92,6 +127,14 @@ class PageNode:
self._serve_started_at = None
self._unique_remote_hashes = set()
+ self.announce_enabled = bool(announce_enabled)
+ self.announce_interval_seconds = normalize_announce_interval_seconds(
+ announce_interval_seconds,
+ )
+ self.last_announced_at = None
+ self.on_announce = on_announce
+ self._announce_timer = None
+
def setup(self):
"""Create directories, load or create identity, set up RNS destination."""
os.makedirs(self.pages_dir, exist_ok=True)
@@ -120,6 +163,11 @@ class PageNode:
self.running = True
self._serve_started_at = time.time()
+
+ if self.announce_enabled:
+ self.announce()
+ self._sync_announce_timer()
+
return self.destination.hash.hex()
def announce(self):
@@ -129,10 +177,60 @@ class PageNode:
app_data = self.name.encode("utf-8")
self.destination.announce(app_data=app_data)
self._ensure_local_path()
+ self.last_announced_at = time.time()
+ if self.on_announce is not None:
+ with contextlib.suppress(Exception):
+ self.on_announce(self)
+
+ def _cancel_announce_timer(self):
+ """Cancel the pending periodic announce timer, if any."""
+ timer = self._announce_timer
+ self._announce_timer = None
+ if timer is not None:
+ with contextlib.suppress(Exception):
+ timer.cancel()
+
+ def _sync_announce_timer(self):
+ """Reschedule the periodic announce timer to match current settings."""
+ self._cancel_announce_timer()
+ if not self.running or not self.announce_enabled:
+ return
+ interval = normalize_announce_interval_seconds(
+ self.announce_interval_seconds,
+ default=0,
+ )
+ if interval <= 0:
+ return
+ timer = threading.Timer(interval, self._announce_timer_fire)
+ timer.daemon = True
+ self._announce_timer = timer
+ timer.start()
+
+ def _announce_timer_fire(self):
+ """Timer callback: announce then reschedule for the next interval."""
+ self._announce_timer = None
+ if not self.running or not self.announce_enabled:
+ return
+ with contextlib.suppress(Exception):
+ self.announce()
+ self._sync_announce_timer()
+
+ def set_announce_settings(
+ self, announce_enabled=None, announce_interval_seconds=None
+ ):
+ """Update announce enablement and/or interval, then resync the periodic timer."""
+ if announce_enabled is not None:
+ self.announce_enabled = bool(announce_enabled)
+ if announce_interval_seconds is not None:
+ self.announce_interval_seconds = normalize_announce_interval_seconds(
+ announce_interval_seconds,
+ )
+ self._sync_announce_timer()
def teardown(self):
"""Deregister handlers and clean up."""
self.running = False
+ self._cancel_announce_timer()
self._serve_started_at = None
self._unique_remote_hashes.clear()
if self.destination:
@@ -424,6 +522,9 @@ class PageNode:
"pages": self.list_pages(),
"files": self.list_files(),
"stats": dict(self._stats),
+ "announce_enabled": self.announce_enabled,
+ "announce_interval_seconds": self.announce_interval_seconds,
+ "last_announced_at": self.last_announced_at,
}
def save_config(self):
@@ -431,6 +532,8 @@ class PageNode:
config = {
"node_id": self.node_id,
"name": self.name,
+ "announce_enabled": self.announce_enabled,
+ "announce_interval_seconds": self.announce_interval_seconds,
}
config_path = os.path.join(self.base_dir, "config.json")
with open(config_path, "w") as f:

diff --git a/meshchatx/src/backend/page_node_manager.py b/meshchatx/src/backend/page_node_manager.py
index eec98f54..b39e4f04 100644
--- a/meshchatx/src/backend/page_node_manager.py
+++ b/meshchatx/src/backend/page_node_manager.py
@@ -16,10 +16,11 @@ from meshchatx.src.backend.page_node import PageNode
class PageNodeManager:
"""Manages multiple PageNode instances."""
- def __init__(self, storage_dir):
+ def __init__(self, storage_dir, on_announce=None):
self.storage_dir = os.path.join(storage_dir, "page_nodes")
os.makedirs(self.storage_dir, exist_ok=True)
self.nodes: dict[str, PageNode] = {}
+ self.on_announce = on_announce
def load_nodes(self):
"""Discover and load all persisted nodes from disk."""
@@ -40,10 +41,19 @@ class PageNodeManager:
node_id=node_id,
name=config["name"],
base_dir=node_dir,
+ announce_enabled=config.get("announce_enabled", True),
+ announce_interval_seconds=config.get("announce_interval_seconds"),
+ on_announce=self.on_announce,
)
self.nodes[node_id] = node
- def create_node(self, name, node_id=None):
+ def create_node(
+ self,
+ name,
+ node_id=None,
+ announce_enabled=True,
+ announce_interval_seconds=None,
+ ):
"""Create a new page node, persist its config, and return it."""
if node_id is None:
node_id = str(uuid.uuid4())
@@ -51,7 +61,14 @@ class PageNodeManager:
node_dir = os.path.join(self.storage_dir, node_id)
os.makedirs(node_dir, exist_ok=True)
- node = PageNode(node_id=node_id, name=name, base_dir=node_dir)
+ node = PageNode(
+ node_id=node_id,
+ name=name,
+ base_dir=node_dir,
+ announce_enabled=announce_enabled,
+ announce_interval_seconds=announce_interval_seconds,
+ on_announce=self.on_announce,
+ )
node.save_config()
self.nodes[node_id] = node
return node
@@ -120,6 +137,20 @@ class PageNodeManager:
except Exception as e:
print(f"Failed to announce page node {node.node_id}: {e}")
+ def set_announce_settings(
+ self, node_id, announce_enabled=None, announce_interval_seconds=None
+ ):
+ """Update a node's announce enablement/interval, persist, and resync its timer."""
+ node = self.nodes.get(node_id)
+ if node is None:
+ raise KeyError(f"Node {node_id} not found")
+ node.set_announce_settings(
+ announce_enabled=announce_enabled,
+ announce_interval_seconds=announce_interval_seconds,
+ )
+ node.save_config()
+ return node
+
def rename_node(self, node_id, new_name):
"""Rename a node and persist the change."""
node = self.nodes.get(node_id)

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index b771b0d6..25ede795 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -26,11 +26,17 @@
:network-recovering="networkRecovering"
:recover-network-label="$t('app.recover_network')"
:open-settings-label="$t('app.open_settings')"
+ :show-open-backups="showDatabaseRecoveryActions"
+ :open-backups-label="$t('app.open_backups')"
+ :auto-recover-label="$t('common.auto_recover')"
+ :auto-recovering="databaseAutoRecovering"
:open-interfaces-label="$t('app.open_interfaces')"
@restart-backend="onRestartBackend"
@view-backend-logs="onViewBackendCrashReport"
@recover-network="onRecoverNetwork"
@open-settings="onOpenSettingsForRecovery"
+ @open-backups="onOpenBackupsForRecovery"
+ @auto-recover-database="onAutoRecoverDatabase"
@open-interfaces="onOpenInterfacesForRecovery"
/>
@@ -642,6 +648,7 @@ import ToneGenerator from "../js/ToneGenerator";
import { listNavItems } from "../js/registries/navRegistry.js";
import { onWsEvent, offWsEvent } from "../js/registries/wsEventRegistry.js";
import { shouldShowMultiSessionToast } from "../js/activeSessions.js";
+import { isDatabaseRecoveryError, recoveryLocationForNetworkError } from "../js/networkRecovery.js";
import { handleLxmIngestUriResult } from "../js/ingestUriResultNavigation.js";
import { applyRelayShareLink, parseMeshchatRelayUri } from "../js/relayLinkUtils.js";
import logoUrl from "../assets/images/logo.png";
@@ -736,6 +743,7 @@ export default {
backendExitCode: null,
backendRestarting: false,
networkRecovering: false,
+ databaseAutoRecovering: false,
userInitiatedPropagationSync: false,
identitySwitchDedupeHash: null,
@@ -853,11 +861,14 @@ export default {
},
networkDegradedBannerLabel() {
const detail = GlobalState.networkDegradedError;
- if (detail) {
- return `${this.$t("app.network_degraded")}: ${detail}`;
+ if (detail && String(detail).trim()) {
+ return String(detail).trim();
}
return this.$t("app.network_degraded");
},
+ showDatabaseRecoveryActions() {
+ return isDatabaseRecoveryError(GlobalState.networkDegradedError);
+ },
identitySidebarLabel() {
const raw = this.displayName;
const name = raw != null && String(raw).trim() !== "" ? String(raw).trim() : "";
@@ -929,6 +940,10 @@ export default {
this._unsubOpenConversations();
this._unsubOpenConversations = null;
}
+ if (typeof this._networkDegradedRecoveryWatchStop === "function") {
+ this._networkDegradedRecoveryWatchStop();
+ this._networkDegradedRecoveryWatchStop = null;
+ }
},
mounted() {
try {
@@ -952,6 +967,13 @@ export default {
// ignore
}
this.startShellAuthWatch();
+ this._networkDegradedRecoveryWatchStop = watch(
+ () => [GlobalState.networkDegraded, GlobalState.networkDegradedError],
+ () => {
+ this.maybeNavigateNetworkRecovery();
+ }
+ );
+ this.maybeNavigateNetworkRecovery();
this.applyShellAppearance();
if (ElectronUtils.isElectron()) {
if (typeof window.electron.onBackendProcessExited === "function") {
@@ -1253,6 +1275,59 @@ export default {
onOpenSettingsForRecovery() {
this.$router.push({ name: "settings" });
},
+ onOpenBackupsForRecovery() {
+ this.$router.push({ name: "about", hash: "#about-database-backups" });
+ },
+ async onAutoRecoverDatabase() {
+ if (this.databaseAutoRecovering) {
+ return;
+ }
+ if (!(await DialogUtils.confirm(this.$t("about.auto_recover_confirm")))) {
+ return;
+ }
+ this.databaseAutoRecovering = true;
+ try {
+ const response = await window.api.post("/api/v1/database/auto-recover", {
+ relaunch: true,
+ });
+ const strategy = response.data?.strategy;
+ const msg = response.data?.message;
+ if (strategy === "restore_backup") {
+ ToastUtils.success(msg || this.$t("about.auto_recover_backup"));
+ if (response.data?.requires_relaunch) {
+ return;
+ }
+ } else if (strategy === "sqlite_recovery") {
+ ToastUtils.success(msg || this.$t("about.recovery_complete"));
+ await this.onRecoverNetwork();
+ } else {
+ ToastUtils.error(msg || this.$t("about.auto_recover_failed"));
+ }
+ } catch (e) {
+ const err =
+ e.response?.data?.message || e.response?.data?.error || this.$t("about.auto_recover_failed");
+ ToastUtils.error(err);
+ } finally {
+ this.databaseAutoRecovering = false;
+ }
+ },
+ maybeNavigateNetworkRecovery() {
+ if (!GlobalState.networkDegraded || this.$route?.name === "auth") {
+ return;
+ }
+ const loc = recoveryLocationForNetworkError(GlobalState.networkDegradedError);
+ if (!loc) {
+ return;
+ }
+ if (
+ loc.name === "about" &&
+ this.$route?.name === "about" &&
+ this.$route?.hash === "#about-database-backups"
+ ) {
+ return;
+ }
+ this.$router.push(loc).catch(() => {});
+ },
async onRecoverNetwork() {
if (this.networkRecovering) {
return;

diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index 389ec6fe..8c8447f2 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -868,6 +868,15 @@
>
<v-icon icon="mdi-broom" start size="14"></v-icon> {{ $t("common.vacuum") }}
</button>
+ <button
+ type="button"
+ class="primary-chip px-4! py-1.5! text-xs!"
+ :disabled="databaseActionInProgress"
+ @click="runAutoRecover"
+ >
+ <v-icon icon="mdi-auto-fix" start size="14"></v-icon>
+ {{ $t("common.auto_recover") }}
+ </button>
<button
type="button"
class="danger-chip px-4! py-1.5! text-xs!"
@@ -934,7 +943,10 @@
</div>
</div>
- <div class="border-t border-zinc-100 dark:border-zinc-800 pt-8 space-y-8">
+ <div
+ id="about-database-backups"
+ class="border-t border-zinc-100 dark:border-zinc-800 pt-8 space-y-8"
+ >
<!-- Backups -->
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div class="space-y-1">
@@ -1090,7 +1102,7 @@
</div>
<!-- Auto Backups -->
- <div v-if="autoBackups && autoBackups.length > 0" class="space-y-6">
+ <div class="space-y-6">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div class="space-y-1">
<div
@@ -1194,6 +1206,12 @@
</div>
</div>
</div>
+ <div
+ v-else
+ class="rounded-xl border border-dashed border-zinc-200 dark:border-zinc-700 px-4 py-6 text-center text-xs text-gray-500"
+ >
+ {{ $t("about.automatic_backups_empty") }}
+ </div>
</div>
</div>
</div>
@@ -1409,6 +1427,13 @@ export default {
return "";
},
},
+ watch: {
+ "$route.hash"() {
+ this.$nextTick(() => {
+ this.scrollToDatabaseBackupsIfNeeded();
+ });
+ },
+ },
mounted() {
this.getAppInfo();
this.getActiveSessions();
@@ -1426,6 +1451,9 @@ export default {
};
onWsEvent("app.sessions.updated", this.sessionsWsHandler);
this.restartAboutPollIntervals();
+ this.$nextTick(() => {
+ this.scrollToDatabaseBackupsIfNeeded();
+ });
},
beforeUnmount() {
if (this.updateInterval) {
@@ -1453,6 +1481,16 @@ export default {
this.listSnapshots();
this.listAutoBackups();
},
+ scrollToDatabaseBackupsIfNeeded() {
+ const hash = typeof this.$route?.hash === "string" ? this.$route.hash : "";
+ if (!hash.includes("about-database-backups")) {
+ return;
+ }
+ const el = document.getElementById("about-database-backups");
+ if (el) {
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ },
restartAboutPollIntervals() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
@@ -1781,6 +1819,44 @@ export default {
this.restoreFileName = "";
}
},
+ async runAutoRecover() {
+ if (this.databaseActionInProgress) {
+ return;
+ }
+ if (!(await DialogUtils.confirm(this.$t("about.auto_recover_confirm")))) {
+ return;
+ }
+ this.databaseActionInProgress = true;
+ this.databaseActionMessage = "";
+ this.databaseActionError = "";
+ try {
+ const response = await window.api.post("/api/v1/database/auto-recover", { relaunch: true });
+ const strategy = response.data?.strategy;
+ const msg = response.data?.message;
+ if (strategy === "restore_backup") {
+ ToastUtils.success(msg || this.$t("about.auto_recover_backup"));
+ if (response.data?.requires_relaunch) {
+ this.scheduleRestoreRelaunch();
+ }
+ } else if (strategy === "sqlite_recovery") {
+ if (response.data.database?.health) {
+ this.databaseHealth = response.data.database.health;
+ }
+ this.databaseRecoveryActions = response.data.database?.actions || [];
+ ToastUtils.success(msg || this.$t("about.recovery_complete"));
+ } else {
+ this.databaseActionError = msg || this.$t("about.auto_recover_failed");
+ ToastUtils.error(this.databaseActionError);
+ }
+ } catch (e) {
+ this.databaseActionError = this.$t("about.auto_recover_failed");
+ const detail = e?.response?.data?.message || e?.response?.data?.error;
+ ToastUtils.error(detail || this.databaseActionError);
+ console.log(e);
+ } finally {
+ this.databaseActionInProgress = false;
+ }
+ },
async runRecovery() {
if (this.databaseActionInProgress) {
return;

diff --git a/meshchatx/src/frontend/components/layout/AppShellBanners.vue b/meshchatx/src/frontend/components/layout/AppShellBanners.vue
index 70545443..23e72716 100644
--- a/meshchatx/src/frontend/components/layout/AppShellBanners.vue
+++ b/meshchatx/src/frontend/components/layout/AppShellBanners.vue
@@ -78,12 +78,30 @@
{{ recoverNetworkLabel }}
</button>
<button
+ v-if="!showOpenBackups"
type="button"
class="rounded-md bg-white/10 px-3 py-1 text-xs font-semibold hover:bg-white/20"
@click="$emit('open-settings')"
>
{{ openSettingsLabel }}
</button>
+ <button
+ v-if="showOpenBackups"
+ type="button"
+ class="rounded-md bg-white/15 px-3 py-1 text-xs font-semibold hover:bg-white/25 disabled:opacity-60"
+ :disabled="autoRecovering"
+ @click="$emit('auto-recover-database')"
+ >
+ {{ autoRecoverLabel }}
+ </button>
+ <button
+ v-if="showOpenBackups"
+ type="button"
+ class="rounded-md bg-white/15 px-3 py-1 text-xs font-semibold hover:bg-white/25"
+ @click="$emit('open-backups')"
+ >
+ {{ openBackupsLabel }}
+ </button>
<button
type="button"
class="rounded-md bg-white/10 px-3 py-1 text-xs font-semibold hover:bg-white/20"
@@ -179,11 +197,35 @@ export default {
type: String,
default: "",
},
+ showOpenBackups: {
+ type: Boolean,
+ default: false,
+ },
+ openBackupsLabel: {
+ type: String,
+ default: "",
+ },
+ autoRecoverLabel: {
+ type: String,
+ default: "",
+ },
+ autoRecovering: {
+ type: Boolean,
+ default: false,
+ },
openInterfacesLabel: {
type: String,
default: "",
},
},
- emits: ["restart-backend", "view-backend-logs", "recover-network", "open-settings", "open-interfaces"],
+ emits: [
+ "restart-backend",
+ "view-backend-logs",
+ "recover-network",
+ "open-settings",
+ "open-backups",
+ "auto-recover-database",
+ "open-interfaces",
+ ],
};
</script>

diff --git a/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue b/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
index 35b397e5..dafe5213 100644
--- a/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
+++ b/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
@@ -49,8 +49,16 @@
:class="node.running ? 'bg-green-500' : 'bg-gray-400'"
></div>
<div class="min-w-0">
- <div class="font-semibold text-gray-900 dark:text-white truncate">
- {{ node.name }}
+ <div class="flex items-center gap-2">
+ <div class="font-semibold text-gray-900 dark:text-white truncate">
+ {{ node.name }}
+ </div>
+ <span
+ v-if="!node.announce_enabled"
+ class="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 shrink-0"
+ >
+ {{ $t("tools.mesh_server.announce_off_badge") }}
+ </span>
</div>
<div
v-if="node.destination_hash"
@@ -111,6 +119,7 @@
<span v-if="node.stats">{{ node.stats.pages_served }} pages</span>
<span v-if="node.stats">{{ node.stats.files_served }} files</span>
<span v-if="node.stats">{{ node.stats.links_established }} links</span>
+ <span>{{ formatLastAnnounced(node.last_announced_at) }}</span>
</div>
</div>
</div>
@@ -152,6 +161,43 @@
<div class="font-mono text-sm select-all">{{ selectedNode.destination_hash }}</div>
</div>
+ <!-- Announce settings -->
+ <div
+ class="p-3 rounded-lg bg-slate-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-700 space-y-3"
+ >
+ <div class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
+ {{ $t("tools.mesh_server.announce_settings") }}
+ </div>
+ <div class="flex items-center justify-between gap-3">
+ <Toggle
+ id="mesh-server-announce-enabled"
+ v-model="announceSettingsForm.announce_enabled"
+ :label="$t('tools.mesh_server.announce_enabled_label')"
+ />
+ </div>
+ <div v-if="announceSettingsForm.announce_enabled" class="flex items-center gap-3">
+ <label for="mesh-server-announce-interval" class="glass-label mb-0 shrink-0">
+ {{ $t("tools.mesh_server.announce_interval_label") }}
+ </label>
+ <input
+ id="mesh-server-announce-interval"
+ v-model.number="announceIntervalMinutes"
+ type="number"
+ min="1"
+ max="1440"
+ class="input-field w-24"
+ />
+ </div>
+ <div class="flex items-center justify-between gap-3">
+ <span class="text-xs text-gray-500 dark:text-gray-400">
+ {{ formatLastAnnounced(selectedNode.last_announced_at) }}
+ </span>
+ <button class="primary-chip py-1! px-3! text-xs!" @click="saveAnnounceSettings">
+ {{ $t("common.save") }}
+ </button>
+ </div>
+ </div>
+
<!-- Tabs: Pages / Files -->
<div class="flex gap-2 border-b border-gray-200/60 dark:border-zinc-800/60">
<button
@@ -357,12 +403,19 @@
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DialogUtils from "../../js/DialogUtils";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
+import Toggle from "../forms/Toggle.vue";
+import Utils from "../../js/Utils";
+
+const DEFAULT_ANNOUNCE_INTERVAL_SECONDS = 900;
+const ANNOUNCE_INTERVAL_MIN_MINUTES = 1;
+const ANNOUNCE_INTERVAL_MAX_MINUTES = 1440;
export default {
name: "PageNodesPage",
components: {
MaterialDesignIcon,
ToolsPageHeader,
+ Toggle,
},
data() {
return {
@@ -380,8 +433,28 @@ export default {
statusMessage: "",
statusSuccess: true,
statusTimeout: null,
+ announceSettingsForm: {
+ announce_enabled: true,
+ announce_interval_seconds: DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+ },
};
},
+ computed: {
+ announceIntervalMinutes: {
+ get() {
+ const seconds =
+ this.announceSettingsForm.announce_interval_seconds || DEFAULT_ANNOUNCE_INTERVAL_SECONDS;
+ return Math.round(seconds / 60);
+ },
+ set(minutes) {
+ const clamped = Math.max(
+ ANNOUNCE_INTERVAL_MIN_MINUTES,
+ Math.min(ANNOUNCE_INTERVAL_MAX_MINUTES, Number(minutes) || ANNOUNCE_INTERVAL_MIN_MINUTES)
+ );
+ this.announceSettingsForm.announce_interval_seconds = clamped * 60;
+ },
+ },
+ },
async mounted() {
await this.loadNodes();
},
@@ -409,6 +482,10 @@ export default {
this.selectedNode = node;
this.detailTab = "pages";
this.editingPage = null;
+ this.announceSettingsForm = {
+ announce_enabled: node.announce_enabled !== false,
+ announce_interval_seconds: node.announce_interval_seconds || DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+ };
},
async createNode() {
if (!this.createNodeName.trim()) return;
@@ -457,10 +534,36 @@ export default {
try {
await window.api.post(`/api/v1/page-nodes/${nodeId}/announce`);
this.showStatus("Announced on mesh", true);
+ await this.loadNodes();
} catch {
this.showStatus("Failed to announce", false);
}
},
+ async saveAnnounceSettings() {
+ if (!this.selectedNode) return;
+ try {
+ const response = await window.api.patch(
+ `/api/v1/page-nodes/${this.selectedNode.node_id}/announce-settings`,
+ {
+ announce_enabled: this.announceSettingsForm.announce_enabled,
+ announce_interval_seconds: this.announceSettingsForm.announce_interval_seconds,
+ }
+ );
+ this.selectedNode = response.data;
+ this.showStatus(this.$t("tools.mesh_server.announce_settings_saved"), true);
+ await this.loadNodes();
+ } catch {
+ this.showStatus(this.$t("tools.mesh_server.announce_settings_failed"), false);
+ }
+ },
+ formatLastAnnounced(lastAnnouncedAt) {
+ if (!lastAnnouncedAt) {
+ return this.$t("tools.mesh_server.never_announced");
+ }
+ return this.$t("tools.mesh_server.last_announced_ago", {
+ time: Utils.formatSecondsAgoForI18n(lastAnnouncedAt),
+ });
+ },
async renameNode() {
if (!this.renameNodeName.trim() || !this.selectedNode) return;
try {

diff --git a/meshchatx/src/frontend/js/networkRecovery.js b/meshchatx/src/frontend/js/networkRecovery.js
index af24a167..77f0ee79 100644
--- a/meshchatx/src/frontend/js/networkRecovery.js
+++ b/meshchatx/src/frontend/js/networkRecovery.js
@@ -23,18 +23,54 @@ const INTERFACE_HINTS = [
"configobj",
];
+const DATABASE_RECOVERY_HINTS = [
+ "database version",
+ "newer than this meshchatx build",
+ "database initialization failed",
+ "schema migration",
+ "restore a backup",
+ "databasetoonew",
+ "premigrationbackup",
+ "postmigrationverification",
+ "quick_check failed",
+ "integrity failure",
+];
+
+/**
+ * @param {unknown} error
+ * @returns {string}
+ */
+export function networkErrorText(error) {
+ return String(error || "").trim();
+}
+
/**
* @param {unknown} error
* @returns {boolean}
*/
export function isLikelyInterfaceRecoveryError(error) {
- const text = String(error || "").toLowerCase();
+ const text = networkErrorText(error).toLowerCase();
if (!text) {
return false;
}
+ if (isDatabaseRecoveryError(error)) {
+ return false;
+ }
return INTERFACE_HINTS.some((hint) => text.includes(hint));
}
+/**
+ * @param {unknown} error
+ * @returns {boolean}
+ */
+export function isDatabaseRecoveryError(error) {
+ const text = networkErrorText(error).toLowerCase();
+ if (!text) {
+ return false;
+ }
+ return DATABASE_RECOVERY_HINTS.some((hint) => text.includes(hint));
+}
+
/**
* Route name to open after degraded startup, or null to leave the user on the
* current page (banner actions still apply).
@@ -47,3 +83,19 @@ export function recoveryRouteForNetworkError(error) {
}
return null;
}
+
+/**
+ * Full route location for degraded startup when the UI should jump to recovery.
+ * @param {unknown} error
+ * @returns {{ name: string, hash?: string } | null}
+ */
+export function recoveryLocationForNetworkError(error) {
+ if (isDatabaseRecoveryError(error)) {
+ return { name: "about", hash: "#about-database-backups" };
+ }
+ const routeName = recoveryRouteForNetworkError(error);
+ if (routeName) {
+ return { name: routeName };
+ }
+ return null;
+}

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index a9efb7ec..f21d9066 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -2699,7 +2699,15 @@
},
"mesh_server": {
"title": "Mesh-Server",
- "description": "Micron-Seiten und Dateien für Peers im Mesh bereitstellen."
+ "description": "Micron-Seiten und Dateien für Peers im Mesh bereitstellen.",
+ "announce_settings": "Ankündigungseinstellungen",
+ "announce_enabled_label": "Automatisch ankündigen",
+ "announce_interval_label": "Ankündigungsintervall (Minuten)",
+ "announce_off_badge": "Automatische Ankündigung aus",
+ "last_announced_ago": "Zuletzt angekündigt vor {time}",
+ "never_announced": "Noch nie angekündigt",
+ "announce_settings_saved": "Ankündigungseinstellungen gespeichert",
+ "announce_settings_failed": "Ankündigungseinstellungen konnten nicht gespeichert werden"
},
"rns_tunnel": {
"title": "RNSTunnel",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 1783ac3d..740b14f3 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -441,6 +441,7 @@
"network_degraded": "RNS unavailable. The app is still running so you can diagnose and recover without wiping data.",
"recover_network": "Retry RNS",
"open_settings": "Open settings",
+ "open_backups": "Open backups",
"open_interfaces": "Open interfaces",
"network_recovered": "RNS recovered",
"network_recover_failed": "Could not recover RNS. Check the error details, then retry or review settings and interfaces.",
@@ -1109,6 +1110,7 @@
"app_name": "MeshChatX",
"automatic_backups_desc": "Automated daily snapshots of your database.",
"automatic_backups_title": "Automatic Backups",
+ "automatic_backups_empty": "No automatic backups yet. Use Restore from file above or create a manual backup after the database is healthy.",
"backup_download_failed": "Failed to download backup",
"backup_downloaded": "Backup downloaded",
"backend_stack": "Python packages",
@@ -1162,6 +1164,9 @@
"recovery": "Recovery",
"recovery_complete": "Database recovery finished.",
"recovery_confirm": "Run database recovery? This runs a full integrity check and compacts the database (VACUUM). It can take a while and the app may feel unresponsive. This does not load a backup file.",
+ "auto_recover_confirm": "Try automatic recovery? MeshChatX will restore the newest backup that matches this app version, or run SQLite repair if no backup fits.",
+ "auto_recover_backup": "Restored from the latest compatible backup. The app will restart.",
+ "auto_recover_failed": "Automatic recovery could not fix the database. Pick a backup manually or restore from file.",
"recovery_failed": "Recovery failed",
"vacuum_complete": "Database vacuum finished.",
"vacuum_failed": "Could not vacuum the database.",
@@ -2909,7 +2914,15 @@
},
"mesh_server": {
"title": "Mesh Server",
- "description": "Publish Micron pages and small files for mesh peers to open."
+ "description": "Publish Micron pages and small files for mesh peers to open.",
+ "announce_settings": "Announce Settings",
+ "announce_enabled_label": "Announce automatically",
+ "announce_interval_label": "Announce interval (minutes)",
+ "announce_off_badge": "Auto-announce off",
+ "last_announced_ago": "Last announced {time} ago",
+ "never_announced": "Never announced",
+ "announce_settings_saved": "Announce settings saved",
+ "announce_settings_failed": "Failed to save announce settings"
},
"rns_tunnel": {
"title": "RNS Tunnel",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index f14f1f26..a71bbb3a 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -2905,7 +2905,15 @@
},
"mesh_server": {
"title": "Servidor Mesh",
- "description": "Host Micron páginas y archivos para pares en la malla."
+ "description": "Host Micron páginas y archivos para pares en la malla.",
+ "announce_settings": "Ajustes de anuncio",
+ "announce_enabled_label": "Anunciar automáticamente",
+ "announce_interval_label": "Intervalo de anuncio (minutos)",
+ "announce_off_badge": "Anuncio automático desactivado",
+ "last_announced_ago": "Último anuncio hace {time}",
+ "never_announced": "Nunca anunciado",
+ "announce_settings_saved": "Ajustes de anuncio guardados",
+ "announce_settings_failed": "No se pudieron guardar los ajustes de anuncio"
},
"rns_tunnel": {
"title": "Tunel RNS",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 48a9410a..3dee76d9 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -2905,7 +2905,15 @@
},
"mesh_server": {
"title": "Mesh-palvelin",
- "description": "Julkaise Micron-sivuja ja pieniä tiedostoja mesh-vertaisten avattavaksi."
+ "description": "Julkaise Micron-sivuja ja pieniä tiedostoja mesh-vertaisten avattavaksi.",
+ "announce_settings": "Ilmoitusasetukset",
+ "announce_enabled_label": "Ilmoita automaattisesti",
+ "announce_interval_label": "Ilmoitusväli (minuuttia)",
+ "announce_off_badge": "Automaattinen ilmoitus pois",
+ "last_announced_ago": "Viimeksi ilmoitettu {time} sitten",
+ "never_announced": "Ei koskaan ilmoitettu",
+ "announce_settings_saved": "Ilmoitusasetukset tallennettu",
+ "announce_settings_failed": "Ilmoitusasetusten tallennus epäonnistui"
},
"rns_tunnel": {
"title": "RNS Tunnel",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 497fb737..b3b9a32c 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -2905,7 +2905,15 @@
},
"mesh_server": {
"title": "Serveur Mesh",
- "description": "Hôte des pages et des fichiers Micron pour les pairs sur le maillage."
+ "description": "Hôte des pages et des fichiers Micron pour les pairs sur le maillage.",
+ "announce_settings": "Paramètres d'annonce",
+ "announce_enabled_label": "Annoncer automatiquement",
+ "announce_interval_label": "Intervalle d'annonce (minutes)",
+ "announce_off_badge": "Annonce automatique désactivée",
+ "last_announced_ago": "Dernière annonce il y a {time}",
+ "never_announced": "Jamais annoncé",
+ "announce_settings_saved": "Paramètres d'annonce enregistrés",
+ "announce_settings_failed": "Impossible d'enregistrer les paramètres d'annonce"
},
"rns_tunnel": {
"title": "Tunnel RNS",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index adb5a385..d8f03fc7 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -2957,7 +2957,15 @@
},
"mesh_server": {
"title": "Mesh Server",
- "description": "Pubblica pagine Micron e file per i peer sulla mesh."
+ "description": "Pubblica pagine Micron e file per i peer sulla mesh.",
+ "announce_settings": "Impostazioni annuncio",
+ "announce_enabled_label": "Annuncia automaticamente",
+ "announce_interval_label": "Intervallo di annuncio (minuti)",
+ "announce_off_badge": "Annuncio automatico disattivato",
+ "last_announced_ago": "Ultimo annuncio {time} fa",
+ "never_announced": "Mai annunciato",
+ "announce_settings_saved": "Impostazioni annuncio salvate",
+ "announce_settings_failed": "Impossibile salvare le impostazioni annuncio"
},
"rns_tunnel": {
"title": "RNS Tunnel",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 0a660b57..1daaacb1 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -2905,7 +2905,15 @@
},
"mesh_server": {
"title": "Mesh-server",
- "description": "Host Micron pagina's en bestanden voor peers op de mesh."
+ "description": "Host Micron pagina's en bestanden voor peers op de mesh.",
+ "announce_settings": "Aankondigingsinstellingen",
+ "announce_enabled_label": "Automatisch aankondigen",
+ "announce_interval_label": "Aankondigingsinterval (minuten)",
+ "announce_off_badge": "Automatische aankondiging uit",
+ "last_announced_ago": "Laatst aangekondigd {time} geleden",
+ "never_announced": "Nog niet aangekondigd",
+ "announce_settings_saved": "Aankondigingsinstellingen opgeslagen",
+ "announce_settings_failed": "Opslaan van aankondigingsinstellingen mislukt"
},
"rns_tunnel": {
"title": "RNS-tunnel",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 5f6ec658..59f4e29f 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -2699,7 +2699,15 @@
},
"mesh_server": {
"title": "Mesh-сервер",
- "description": "Публикация страниц Micron и файлов для узлов в mesh."
+ "description": "Публикация страниц Micron и файлов для узлов в mesh.",
+ "announce_settings": "Настройки анонсов",
+ "announce_enabled_label": "Анонсировать автоматически",
+ "announce_interval_label": "Интервал анонса (минуты)",
+ "announce_off_badge": "Автоанонс выключен",
+ "last_announced_ago": "Последний анонс {time} назад",
+ "never_announced": "Анонсов еще не было",
+ "announce_settings_saved": "Настройки анонса сохранены",
+ "announce_settings_failed": "Не удалось сохранить настройки анонса"
},
"rns_tunnel": {
"title": "RNS Tunnel",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 75e0f3f8..27e2bc64 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -2905,7 +2905,15 @@
},
"mesh_server": {
"title": "网格服务器",
- "description": "主机Micron页面和文件用于网格上的对等端."
+ "description": "主机Micron页面和文件用于网格上的对等端.",
+ "announce_settings": "公告设置",
+ "announce_enabled_label": "自动公告",
+ "announce_interval_label": "公告间隔(分钟)",
+ "announce_off_badge": "自动公告已关闭",
+ "last_announced_ago": "{time}前最后公告",
+ "never_announced": "尚未公告",
+ "announce_settings_saved": "公告设置已保存",
+ "announce_settings_failed": "保存公告设置失败"
},
"rns_tunnel": {
"title": "RNS 隧道",

diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index fc5b1cd0..cb3a7d87 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -19,7 +19,7 @@ import { registerCoreContributions } from "./js/registries/registerCoreContribut
import { installWsEventBridge } from "./js/registries/wsEventBridge.js";
import { pluginHost } from "./js/plugins/PluginHost.js";
import GlobalState from "./js/GlobalState.js";
-import { recoveryRouteForNetworkError } from "./js/networkRecovery.js";
+import { recoveryLocationForNetworkError } from "./js/networkRecovery.js";
import ElectronUtils from "./js/ElectronUtils.js";
import {
decideControllerChangeReload,
@@ -554,10 +554,10 @@ if (networkReady) {
});
}
if (GlobalState.networkDegraded) {
- const recoveryRoute = recoveryRouteForNetworkError(GlobalState.networkDegradedError);
- if (recoveryRoute) {
+ const recoveryLocation = recoveryLocationForNetworkError(GlobalState.networkDegradedError);
+ if (recoveryLocation) {
try {
- router.replace({ name: recoveryRoute });
+ router.replace(recoveryLocation);
} catch {
// Route may not exist yet during early boot, but the banner still guides the user.
}

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md b/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md
index e9154da6..e4e1ef29 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md
@@ -79,14 +79,14 @@ Use the search bar to query both sets at once. MeshChatX guide text is currently
## Storage locations
-| Data | Typical path (CLI default) |
-| --------------------- | -------------------------------------------------- |
-| MeshChatX app data | `./storage` (or `--storage-dir` / `MESHCHAT_STORAGE_DIR`) |
-| Reticulum config | `~/.reticulum` (or `--reticulum-config-dir` / `MESHCHAT_RETICULUM_CONFIG_DIR`) |
+| Data | Typical path (CLI default) |
+| --------------------- | ---------------------------------------------------------------------------------------------- |
+| MeshChatX app data | `./storage` (or `--storage-dir` / `MESHCHAT_STORAGE_DIR`) |
+| Reticulum config | `~/.reticulum` (or `--reticulum-config-dir` / `MESHCHAT_RETICULUM_CONFIG_DIR`) |
| Portable bundle | `<data-dir>/storage` and `<data-dir>/.reticulum` when using `--data-dir` / `MESHCHAT_DATA_DIR` |
-| Desktop Electron data | `~/.reticulum-meshchatx` and `~/.reticulum` unless overridden at launch |
-| Per-identity database | `<storage>/identities/<identity_hash>/database.db` |
-| Docker volume | `meshchatx-config` mounted at `/config` |
+| Desktop Electron data | `~/.reticulum-meshchatx` and `~/.reticulum` unless overridden at launch |
+| Per-identity database | `<storage>/identities/<identity_hash>/database.db` |
+| Docker volume | `meshchatx-config` mounted at `/config` |
Legacy upstream data may still exist under `~/.reticulum-meshchat/`. Migration tooling can move you to the MeshChatX layout.

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md b/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md
index 83b7a7f6..188c2cda 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md
@@ -130,23 +130,23 @@ Open the UI at the host and port you chose. HTTPS is enabled by default with a s
Common flags and environment variables:
-| Flag | Environment variable | Default | Description |
-| ------------------------ | ------------------------ | -------------- | ---------------------------------- |
-| `--host` | `MESHCHAT_HOST` | `127.0.0.1` | Bind address |
-| `--port` | `MESHCHAT_PORT` | `8000` | HTTP or HTTPS port |
-| `--no-https` | `MESHCHAT_NO_HTTPS` | false | Serve plain HTTP |
-| `--ssl-cert` | `MESHCHAT_SSL_CERT` | auto | TLS certificate path |
-| `--ssl-key` | `MESHCHAT_SSL_KEY` | auto | TLS private key path |
-| `--headless` | `MESHCHAT_HEADLESS` | false | Do not open a browser |
-| `--auth` | `MESHCHAT_AUTH` | false | Require HTTP basic auth for the UI |
-| `--storage-dir` | `MESHCHAT_STORAGE_DIR` | `./storage` | Application data directory |
-| `--reticulum-config-dir` | `MESHCHAT_RETICULUM_CONFIG_DIR` | `~/.reticulum` | Reticulum configuration |
+| Flag | Environment variable | Default | Description |
+| ------------------------ | ------------------------------- | -------------- | ----------------------------------------------------------------------------------- |
+| `--host` | `MESHCHAT_HOST` | `127.0.0.1` | Bind address |
+| `--port` | `MESHCHAT_PORT` | `8000` | HTTP or HTTPS port |
+| `--no-https` | `MESHCHAT_NO_HTTPS` | false | Serve plain HTTP |
+| `--ssl-cert` | `MESHCHAT_SSL_CERT` | auto | TLS certificate path |
+| `--ssl-key` | `MESHCHAT_SSL_KEY` | auto | TLS private key path |
+| `--headless` | `MESHCHAT_HEADLESS` | false | Do not open a browser |
+| `--auth` | `MESHCHAT_AUTH` | false | Require HTTP basic auth for the UI |
+| `--storage-dir` | `MESHCHAT_STORAGE_DIR` | `./storage` | Application data directory |
+| `--reticulum-config-dir` | `MESHCHAT_RETICULUM_CONFIG_DIR` | `~/.reticulum` | Reticulum configuration |
| `--data-dir` | `MESHCHAT_DATA_DIR` | none | Portable root (`storage` + `.reticulum` subdirs when the two paths above are unset) |
-| `--identity-file` | `MESHCHAT_IDENTITY_FILE` | none | Load identity from file |
-| `--rns-log-level` | `MESHCHAT_RNS_LOG_LEVEL` | none | Reticulum log level |
-| `--auto-recover` | `MESHCHAT_AUTO_RECOVER` | false | Attempt SQLite recovery on start |
-| `--emergency` | | false | Start without database |
-| `--disable-plugins` | | false | Disable the plugin system |
+| `--identity-file` | `MESHCHAT_IDENTITY_FILE` | none | Load identity from file |
+| `--rns-log-level` | `MESHCHAT_RNS_LOG_LEVEL` | none | Reticulum log level |
+| `--auto-recover` | `MESHCHAT_AUTO_RECOVER` | false | Attempt SQLite recovery on start |
+| `--emergency` | | false | Start without database |
+| `--disable-plugins` | | false | Disable the plugin system |
CLI flags override environment variables when both are set.
@@ -177,7 +177,14 @@ meshchatx --headless \
--reticulum-config-dir="$PERSIST/.reticulum"
```
-On Windows portable Electron builds, storage and Reticulum config default next to the `.exe` when `PORTABLE_EXECUTABLE_DIR` is set. On Linux and macOS desktop builds, Electron still defaults to `~/.reticulum-meshchatx` and `~/.reticulum` unless you pass the flags above (or set `MESHCHAT_DATA_DIR` / `MESHCHAT_STORAGE_DIR` / `MESHCHAT_RETICULUM_CONFIG_DIR` in the environment before launch).
+The Electron desktop app (AppImage, portable exe, macOS bundle) honors the same `--data-dir` / `--storage-dir` / `--reticulum-config-dir` flags (or the matching `MESHCHAT_DATA_DIR` / `MESHCHAT_STORAGE_DIR` / `MESHCHAT_RETICULUM_CONFIG_DIR` environment variables) on every platform, not just Windows:
+
+```bash
+export PERSIST="/media/amnesia/Persistent/meshchatx"
+./MeshChatX-x86_64.AppImage --data-dir="$PERSIST"
+```
+
+On Windows portable exe builds, storage and Reticulum config also default next to the `.exe` when `PORTABLE_EXECUTABLE_DIR` is set (used by the portable target automatically), without needing any flags.
## Reticulum manual bundle

diff --git a/meshchatx/src/path_utils.py b/meshchatx/src/path_utils.py
index 67bd9aff..9c3cfba7 100644
--- a/meshchatx/src/path_utils.py
+++ b/meshchatx/src/path_utils.py
@@ -58,7 +58,12 @@ def resolve_path_under_dir(directory: str, user_path: str) -> str | None:
def resolve_log_dir():
- """Choose a writable log directory across container, desktop, and Windows."""
+ """Choose a writable log directory across container, desktop, and Windows.
+
+ Honors MESHCHAT_STORAGE_DIR first, then falls back to deriving a logs
+ directory from MESHCHAT_DATA_DIR (portable mode) when storage dir is
+ unset, matching resolve_meshchat_data_roots precedence.
+ """
env_dir = os.environ.get("MESHCHAT_LOG_DIR")
candidates = []
if env_dir:
@@ -67,6 +72,16 @@ def resolve_log_dir():
storage_dir = os.environ.get("MESHCHAT_STORAGE_DIR")
if storage_dir:
candidates.append(os.path.join(storage_dir, "logs"))
+ else:
+ data_dir = os.environ.get("MESHCHAT_DATA_DIR")
+ if data_dir:
+ candidates.append(
+ os.path.join(
+ os.path.abspath(os.path.expanduser(data_dir)),
+ "storage",
+ "logs",
+ ),
+ )
candidates.append("/config/logs")

diff --git a/package.json b/package.json
index a7df7e5a..c9eee3c7 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,7 @@
"dist:linux-arm64": "pnpm run electron-postinstall && cross-env PLATFORM=linux ARCH=arm64 pnpm run build && electron-builder --linux AppImage deb --arm64 --publish=never",
"dist:linux-arm64:offline": "cross-env MESHCHATX_OFFLINE_BUILD=1 pnpm run dist:linux-arm64",
"dist:rpm": "pnpm run electron-postinstall && cross-env PLATFORM=linux pnpm run build && electron-builder --linux rpm --publish=never",
+ "dist:apk": "pnpm run electron-postinstall && cross-env PLATFORM=linux pnpm run build && electron-builder --linux apk --publish=never",
"dist:flatpak": "pnpm run electron-postinstall && cross-env PLATFORM=linux pnpm run build && electron-builder --linux flatpak --publish=never",
"dist:flatpak-prebuilt": "pnpm run electron-postinstall && cross-env PLATFORM=linux pnpm run version:sync && cross-env PLATFORM=linux pnpm run build-backend && electron-builder --linux flatpak --x64 --publish=never",
"dist:windows": "pnpm run electron-postinstall && cross-env PLATFORM=win32 pnpm run build && electron-builder --win portable nsis --publish=never",

diff --git a/scripts/ci/docker-build-entry.sh b/scripts/ci/docker-build-entry.sh
index c416c8f3..5402c1f7 100755
--- a/scripts/ci/docker-build-entry.sh
+++ b/scripts/ci/docker-build-entry.sh
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Run inside Dockerfile.build after COPY. Writes outputs to /artifacts.
-# Env: MESHCHATX_BUILD_TARGETS = all | wheel | electron (electron = AppImage+deb per arch + best-effort RPM, no wheel)
+# Env: MESHCHATX_BUILD_TARGETS = all | wheel | electron (electron = Linux packages, no wheel)
+# Env: MESHCHATX_LINUX_FORMATS = comma list of appimage,deb,rpm,apk (default: all four)
set -euo pipefail
cd /src

diff --git a/scripts/ci/github-apt-linux-packaging.sh b/scripts/ci/github-apt-linux-packaging.sh
index 1192de67..616c8c8e 100755
--- a/scripts/ci/github-apt-linux-packaging.sh
+++ b/scripts/ci/github-apt-linux-packaging.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# APT packages needed for Linux Electron packaging (AppImage, deb, rpm) on Debian/Ubuntu or in Dockerfile.build (root).
+# APT packages needed for Linux Electron packaging (AppImage, deb, rpm, apk) on Debian/Ubuntu or in Dockerfile.build (root).
set -euo pipefail
# shellcheck source=priv.sh disable=SC1091

diff --git a/scripts/ci/github-build-linux-release-assets.sh b/scripts/ci/github-build-linux-release-assets.sh
index e80783ed..f02778af 100755
--- a/scripts/ci/github-build-linux-release-assets.sh
+++ b/scripts/ci/github-build-linux-release-assets.sh
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
-# Build wheel, Linux AppImage/deb (x64 + arm64), optional RPM, frontend zip, and SBOM under ./release-assets/.
+# Build wheel, Linux AppImage/deb (x64 + arm64), optional RPM/APK, frontend zip, and SBOM under ./release-assets/.
# Expects repo root as cwd, dependencies installed (task install / pnpm), and meshchatx/public populated when building Electron.
# Optional: SKIP_WHEEL=1, SKIP_ELECTRON=1, TRIVY_SBOM=0
+# Optional: MESHCHATX_LINUX_FORMATS = comma list of appimage,deb,rpm,apk (default: all four)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
@@ -33,6 +34,33 @@ case "$HOST_ARCH" in
*) NATIVE_ARCH="$HOST_ARCH" ;;
esac
+LINUX_FORMATS="${MESHCHATX_LINUX_FORMATS:-all}"
+if [ "$LINUX_FORMATS" = "all" ]; then
+ LINUX_FORMATS="appimage,deb,rpm,apk"
+fi
+
+has_format() {
+ case ",$LINUX_FORMATS," in
+ *",$1,"*) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+for _fmt in ${LINUX_FORMATS//,/ }; do
+ case "$_fmt" in
+ appimage|deb|rpm|apk) ;;
+ *)
+ echo "Unknown Linux package format '$_fmt' in MESHCHATX_LINUX_FORMATS (expected appimage, deb, rpm, apk, or all)" >&2
+ exit 1
+ ;;
+ esac
+done
+
+appimage_deb_targets=""
+has_format appimage && appimage_deb_targets="$appimage_deb_targets AppImage"
+has_format deb && appimage_deb_targets="$appimage_deb_targets deb"
+appimage_deb_targets="${appimage_deb_targets# }"
+
if [ "${SKIP_WHEEL:-0}" != 1 ]; then
if [ "$NATIVE_ARCH" = "x64" ]; then
echo "Building Python wheel..."
@@ -45,26 +73,45 @@ else
fi
if [ "${SKIP_ELECTRON:-0}" != 1 ]; then
- if [ "$NATIVE_ARCH" = "x64" ]; then
- echo "Electron linux x64..."
- pnpm run dist:linux-x64
- elif [ "$NATIVE_ARCH" = "arm64" ]; then
- echo "Electron linux arm64..."
- pnpm run dist:linux-arm64
+ if [ -n "$appimage_deb_targets" ]; then
+ pnpm run electron-postinstall
+ if [ "$NATIVE_ARCH" = "x64" ]; then
+ echo "Electron linux x64 ($appimage_deb_targets)..."
+ PLATFORM=linux ARCH=x64 pnpm run build
+ # shellcheck disable=SC2086
+ pnpm exec electron-builder --linux $appimage_deb_targets --x64 --publish=never
+ elif [ "$NATIVE_ARCH" = "arm64" ]; then
+ echo "Electron linux arm64 ($appimage_deb_targets)..."
+ PLATFORM=linux ARCH=arm64 pnpm run build
+ # shellcheck disable=SC2086
+ pnpm exec electron-builder --linux $appimage_deb_targets --arm64 --publish=never
+ fi
+ else
+ echo "Skipping AppImage/deb (not selected in MESHCHATX_LINUX_FORMATS)."
fi
if [ "$NATIVE_ARCH" = "x64" ]; then
- echo "RPM (best-effort)..."
- if ! task dist:fe:rpm; then
- echo "RPM build failed or skipped; continuing." >&2
+ if has_format rpm; then
+ echo "RPM (best-effort)..."
+ if ! task dist:fe:rpm; then
+ echo "RPM build failed or skipped; continuing." >&2
+ fi
+ fi
+ if has_format apk; then
+ echo "APK (best-effort)..."
+ if ! task dist:fe:apk; then
+ echo "APK build failed or skipped; continuing." >&2
+ fi
fi
+ elif has_format rpm || has_format apk; then
+ echo "Skipping RPM/APK on $NATIVE_ARCH runner (built on x64 only)." >&2
fi
else
echo "Skipping Electron packages (SKIP_ELECTRON=1)."
fi
echo "Collecting release files..."
-find dist -maxdepth 1 -type f \( -name "*-linux*.AppImage" -o -name "*-linux*.deb" -o -name "*-linux*.rpm" \) -exec cp -f {} release-assets/ \; 2>/dev/null || true
+find dist -maxdepth 1 -type f \( -name "*-linux*.AppImage" -o -name "*-linux*.deb" -o -name "*-linux*.rpm" -o -name "*-linux*.apk" \) -exec cp -f {} release-assets/ \; 2>/dev/null || true
find python-dist -maxdepth 1 -type f -name "*.whl" -exec cp -f {} release-assets/ \; 2>/dev/null || true
if [ -d meshchatx/public ] && [ "${SKIP_ELECTRON:-0}" != 1 ]; then

diff --git a/tests/backend/fixtures/backend_module_ownership.json b/tests/backend/fixtures/backend_module_ownership.json
new file mode 100644
index 00000000..dc8baf2c
--- /dev/null
+++ b/tests/backend/fixtures/backend_module_ownership.json
@@ -0,0 +1,125 @@
+{
+ "version": 1,
+ "domains": [
+ {
+ "domain": "Identity",
+ "manager_modules": ["identity_context.py", "identity_manager.py"],
+ "http_route_module": ["http/routes/identities.py"],
+ "ws_module": ["http/ws/dispatch.py"],
+ "primary_tests": ["test_identity_*"],
+ "frontend_page": []
+ },
+ {
+ "domain": "Status / startup",
+ "manager_modules": ["lifecycle/deferred_network.py", "rns_startup_recovery.py"],
+ "http_route_module": ["http/routes/status.py"],
+ "ws_module": [],
+ "primary_tests": ["test_deferred_network_startup.py", "test_api_json_contracts.py"],
+ "frontend_page": []
+ },
+ {
+ "domain": "Auth / CSRF",
+ "manager_modules": ["csrf.py", "app_security_settings.py", "ip_allowlist.py"],
+ "http_route_module": ["http/routes/auth.py"],
+ "ws_module": ["http/ws/dispatch.py"],
+ "primary_tests": ["test_csrf*", "test_access_attempts_*"],
+ "frontend_page": ["App.vue"]
+ },
+ {
+ "domain": "Messages / LXMF",
+ "manager_modules": ["message_handler.py", "lxmf_utils.py", "database/messages.py"],
+ "http_route_module": ["http/routes/messages.py", "lxmf.py"],
+ "ws_module": ["http/ws/dispatch.py"],
+ "primary_tests": [],
+ "frontend_page": ["messages/MessagesPage.vue", "ConversationViewer.vue"]
+ },
+ {
+ "domain": "Telephone / LXST",
+ "manager_modules": ["telephone_manager.py", "database/telephone.py"],
+ "http_route_module": ["http/routes/telephone.py", "contacts.py"],
+ "ws_module": ["http/ws/dispatch.py"],
+ "primary_tests": [],
+ "frontend_page": ["call/CallPage.vue"]
+ },
+ {
+ "domain": "RRC",
+ "manager_modules": ["rrc/manager.py", "rrc/server.py"],
+ "http_route_module": ["http/routes/rrc.py"],
+ "ws_module": ["http/ws/dispatch.py"],
+ "primary_tests": [],
+ "frontend_page": ["relay/RelayChatPage.vue"]
+ },
+ {
+ "domain": "Map",
+ "manager_modules": ["map_manager.py", "map_overlay_manager.py"],
+ "http_route_module": ["http/routes/map.py"],
+ "ws_module": [],
+ "primary_tests": [],
+ "frontend_page": ["map/MapPage.vue"]
+ },
+ {
+ "domain": "Plugins",
+ "manager_modules": ["plugin_manager.py", "plugin_permissions.py"],
+ "http_route_module": ["http/routes/plugins.py", "sideband.py"],
+ "ws_module": ["http/ws/dispatch.py"],
+ "primary_tests": [],
+ "frontend_page": ["settings/PluginsSettingsSection.vue"]
+ },
+ {
+ "domain": "FileSync",
+ "manager_modules": ["rns_filesync_handler.py"],
+ "http_route_module": ["http/routes/filesync.py"],
+ "ws_module": [],
+ "primary_tests": [],
+ "frontend_page": ["filesync/"]
+ },
+ {
+ "domain": "Interfaces",
+ "manager_modules": ["interface_editor.py", "interface_config_parser.py"],
+ "http_route_module": ["http/routes/interfaces.py"],
+ "ws_module": [],
+ "primary_tests": [],
+ "frontend_page": ["interfaces/InterfacesPage.vue"]
+ },
+ {
+ "domain": "Database / backup",
+ "manager_modules": ["database/*", "recovery/*"],
+ "http_route_module": ["http/routes/database.py", "maintenance.py"],
+ "ws_module": [],
+ "primary_tests": [],
+ "frontend_page": []
+ },
+ {
+ "domain": "Docs",
+ "manager_modules": ["docs_manager.py"],
+ "http_route_module": ["http/routes/docs.py"],
+ "ws_module": [],
+ "primary_tests": [],
+ "frontend_page": ["docs/DocsPage.vue"]
+ },
+ {
+ "domain": "Sandbox",
+ "manager_modules": ["landlock_sandbox.py", "seccomp_sandbox.py"],
+ "http_route_module": ["http/routes/status.py"],
+ "ws_module": [],
+ "primary_tests": ["test_landlock_sandbox.py", "test_landlock_integration_surfaces.py"],
+ "frontend_page": []
+ },
+ {
+ "domain": "RNS Link API",
+ "manager_modules": ["rns_link_manager.py"],
+ "http_route_module": [],
+ "ws_module": ["http/ws/dispatch.py"],
+ "primary_tests": ["test_rns_link_*"],
+ "frontend_page": []
+ },
+ {
+ "domain": "Config",
+ "manager_modules": ["config_manager.py"],
+ "http_route_module": ["http/routes/config.py"],
+ "ws_module": ["http/ws/dispatch.py"],
+ "primary_tests": [],
+ "frontend_page": ["settings/SettingsPage.vue"]
+ }
+ ]
+}

diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index 943f9501..7ab10e06 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -164,6 +164,10 @@
"method": "POST",
"path": "/api/v1/database/recover"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/database/auto-recover"
+ },
{
"method": "POST",
"path": "/api/v1/database/restore"
@@ -828,6 +832,10 @@
"method": "POST",
"path": "/api/v1/page-nodes/{node_id}/announce"
},
+ {
+ "method": "PATCH",
+ "path": "/api/v1/page-nodes/{node_id}/announce-settings"
+ },
{
"method": "GET",
"path": "/api/v1/page-nodes/{node_id}/files"

diff --git a/tests/backend/fixtures/schema_versions/manifest.json b/tests/backend/fixtures/schema_versions/manifest.json
index e7ad7ee2..01ff2030 100644
--- a/tests/backend/fixtures/schema_versions/manifest.json
+++ b/tests/backend/fixtures/schema_versions/manifest.json
@@ -1,6 +1,6 @@
{
- "latest_version": "53",
- "v51": "schema_v51.db",
+ "latest_version": "54",
"v52": "schema_v52.db",
- "v53": "schema_v53.db"
+ "v53": "schema_v53.db",
+ "v54": "schema_v54.db"
}

diff --git a/tests/backend/module_ownership_contract_helpers.py b/tests/backend/module_ownership_contract_helpers.py
new file mode 100644
index 00000000..d51c86d2
--- /dev/null
+++ b/tests/backend/module_ownership_contract_helpers.py
@@ -0,0 +1,145 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Helpers for the backend module ownership contract.
+
+Parses the "Backend" table in docs/agents/module-ownership.md into a plain
+row structure, and resolves the backtick-quoted spans in that table to
+on-disk paths under meshchatx/src/backend/ or
+meshchatx/src/frontend/components/. Prose cells without backtick spans
+(for example "(none)" or "App shell banners") resolve to an empty list,
+since they describe behaviour rather than referencing a concrete file.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+BACKEND_ROOT = "meshchatx/src/backend"
+FRONTEND_COMPONENTS_ROOT = "meshchatx/src/frontend/components"
+
+_HEADER_KEY_MAP = {
+ "domain": "domain",
+ "manager modules": "manager_modules",
+ "http route module": "http_route_module",
+ "ws module": "ws_module",
+ "primary tests": "primary_tests",
+ "frontend page": "frontend_page",
+}
+
+_CODE_SPAN_RE = re.compile(r"`([^`]+)`")
+
+
+def _split_row(line: str) -> list[str]:
+ cells = line.strip().split("|")
+ if cells and cells[0].strip() == "":
+ cells = cells[1:]
+ if cells and cells[-1].strip() == "":
+ cells = cells[:-1]
+ return [c.strip() for c in cells]
+
+
+def _code_spans(cell: str) -> list[str]:
+ return _CODE_SPAN_RE.findall(cell)
+
+
+def parse_backend_ownership_table(doc_path: Path) -> list[dict]:
+ """Parse the Backend markdown table into a list of row dicts.
+
+ Each row maps header keys to lists of backtick-quoted spans, except
+ "domain" which maps to the plain cell text. Only the pipe table found
+ directly under the "## Backend" heading is parsed.
+ """
+ lines = doc_path.read_text(encoding="utf-8").splitlines()
+
+ start = None
+ for i, line in enumerate(lines):
+ if line.strip() == "## Backend":
+ start = i + 1
+ break
+ if start is None:
+ raise ValueError("Backend section heading not found in module-ownership.md")
+
+ end = len(lines)
+ for i in range(start, len(lines)):
+ if lines[i].startswith("## "):
+ end = i
+ break
+
+ table_lines = [line for line in lines[start:end] if line.strip().startswith("|")]
+ if len(table_lines) < 2:
+ raise ValueError("Backend table not found under the Backend heading")
+
+ header = [h.lower() for h in _split_row(table_lines[0])]
+ keys = [_HEADER_KEY_MAP[h] for h in header]
+
+ rows = []
+ for line in table_lines[2:]:
+ cells = _split_row(line)
+ row = {}
+ for key, cell in zip(keys, cells):
+ row[key] = cell if key == "domain" else _code_spans(cell)
+ rows.append(row)
+ return rows
+
+
+def write_ownership_fixture(fixture_path: Path, rows: list[dict]) -> None:
+ payload = {"version": 1, "domains": rows}
+ fixture_path.write_text(json.dumps(payload, indent=4) + "\n", encoding="utf-8")
+
+
+def load_ownership_fixture(fixture_path: Path) -> list[dict]:
+ payload = json.loads(fixture_path.read_text(encoding="utf-8"))
+ return payload["domains"]
+
+
+def _resolve_spans(
+ spans: list[str],
+ root: str,
+ default_dir: str = "",
+ carry_forward: bool = False,
+) -> list[tuple[str, str]]:
+ """Resolve backtick spans to (relative_path, kind) pairs, kind in {file, dir}.
+
+ A span ending in "/*" or "/" is a directory reference. A span containing
+ "/" is a fully qualified path and updates the carried directory. A bare
+ filename resolves against the carried directory when carry_forward is
+ set, otherwise against default_dir.
+ """
+ resolved: list[tuple[str, str]] = []
+ current_dir = default_dir
+ for span in spans:
+ if span.endswith("/*"):
+ rel_dir = span[:-2]
+ resolved.append((f"{root}/{rel_dir}", "dir"))
+ continue
+ if span.endswith("/"):
+ rel_dir = span[:-1]
+ base = f"{root}/{current_dir}" if current_dir else root
+ resolved.append((f"{base}/{rel_dir}", "dir"))
+ continue
+ if "/" in span:
+ current_dir = span.rsplit("/", 1)[0]
+ resolved.append((f"{root}/{span}", "file"))
+ continue
+ base_dir = current_dir if carry_forward else default_dir
+ rel = f"{base_dir}/{span}" if base_dir else span
+ resolved.append((f"{root}/{rel}", "file"))
+ return resolved
+
+
+def resolve_manager_module_paths(spans: list[str]) -> list[tuple[str, str]]:
+ return _resolve_spans(spans, BACKEND_ROOT)
+
+
+def resolve_http_route_paths(spans: list[str]) -> list[tuple[str, str]]:
+ return _resolve_spans(spans, BACKEND_ROOT, default_dir="http/routes")
+
+
+def resolve_ws_module_paths(spans: list[str]) -> list[tuple[str, str]]:
+ return _resolve_spans(spans, BACKEND_ROOT)
+
+
+def resolve_frontend_page_paths(spans: list[str]) -> list[tuple[str, str]]:
+ return _resolve_spans(spans, FRONTEND_COMPONENTS_ROOT, carry_forward=True)

diff --git a/tests/backend/schema_versions_contract_helpers.py b/tests/backend/schema_versions_contract_helpers.py
new file mode 100644
index 00000000..5dcdcd2b
--- /dev/null
+++ b/tests/backend/schema_versions_contract_helpers.py
@@ -0,0 +1,41 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Helpers for the schema version fixture manifest contract check."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+
+def derive_schema_versions_manifest(latest_version: int) -> dict[str, str]:
+ """Return the manifest entries expected for a given latest schema version.
+
+ Mirrors the fixture selection in scripts/ci/schema_fixture_generate.py:
+ the latest version plus up to two prior versions when they exist.
+ """
+ versions = [latest_version]
+ if latest_version >= 1:
+ versions.append(latest_version - 1)
+ if latest_version >= 2:
+ versions.append(latest_version - 2)
+ versions = sorted(set(versions))
+
+ manifest: dict[str, str] = {"latest_version": str(latest_version)}
+ for ver in versions:
+ manifest[f"v{ver}"] = f"schema_v{ver}.db"
+ return manifest
+
+
+def load_schema_versions_manifest(fixture_path: Path) -> dict[str, str]:
+ return json.loads(fixture_path.read_text(encoding="utf-8"))
+
+
+def write_schema_versions_manifest(
+ fixture_path: Path, manifest: dict[str, str]
+) -> None:
+ fixture_path.parent.mkdir(parents=True, exist_ok=True)
+ fixture_path.write_text(
+ json.dumps(manifest, indent=2) + "\n",
+ encoding="utf-8",
+ )

diff --git a/tests/backend/test_bot_handler_extended.py b/tests/backend/test_bot_handler_extended.py
index 23d1b394..51297d49 100644
--- a/tests/backend/test_bot_handler_extended.py
+++ b/tests/backend/test_bot_handler_extended.py
@@ -22,6 +22,51 @@ def test_bot_handler_init(temp_identity_dir):
assert handler.bots_state == []
+def test_bot_handler_default_reticulum_dir_falls_back_to_home(
+ temp_identity_dir,
+ monkeypatch,
+):
+ monkeypatch.delenv("MESHCHAT_BOT_RETICULUM_CONFIG_DIR", raising=False)
+ handler = BotHandler(temp_identity_dir)
+ assert handler.bot_reticulum_config_dir == os.path.abspath(
+ os.path.expanduser("~/.reticulum"),
+ )
+
+
+def test_bot_handler_uses_app_reticulum_config_dir_for_portable_mode(
+ temp_identity_dir,
+ tmp_path,
+ monkeypatch,
+):
+ """Bots must stay inside a custom --data-dir / --reticulum-config-dir.
+
+ Root instead of leaking their own RNS instance to the home directory.
+ """
+ monkeypatch.delenv("MESHCHAT_BOT_RETICULUM_CONFIG_DIR", raising=False)
+ portable_reticulum_dir = tmp_path / "persist" / ".reticulum"
+ handler = BotHandler(
+ temp_identity_dir,
+ default_reticulum_config_dir=str(portable_reticulum_dir),
+ )
+ assert handler.bot_reticulum_config_dir == os.path.abspath(
+ str(portable_reticulum_dir),
+ )
+
+
+def test_bot_handler_env_override_wins_over_app_reticulum_config_dir(
+ temp_identity_dir,
+ tmp_path,
+ monkeypatch,
+):
+ override_dir = tmp_path / "separate-bot-rns"
+ monkeypatch.setenv("MESHCHAT_BOT_RETICULUM_CONFIG_DIR", str(override_dir))
+ handler = BotHandler(
+ temp_identity_dir,
+ default_reticulum_config_dir=str(tmp_path / "persist" / ".reticulum"),
+ )
+ assert handler.bot_reticulum_config_dir == os.path.abspath(str(override_dir))
+
+
def test_bot_handler_load_save_state(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
test_state = [{"id": "bot1", "enabled": True, "storage_dir": "some/path"}]

diff --git a/tests/backend/test_data_dir_roots.py b/tests/backend/test_data_dir_roots.py
index 22e2985e..4471765d 100644
--- a/tests/backend/test_data_dir_roots.py
+++ b/tests/backend/test_data_dir_roots.py
@@ -2,7 +2,7 @@
import os
-from meshchatx.src.path_utils import resolve_meshchat_data_roots
+from meshchatx.src.path_utils import resolve_log_dir, resolve_meshchat_data_roots
def test_data_dir_unset_passes_through_explicit_roots():
@@ -49,3 +49,51 @@ def test_data_dir_from_env(monkeypatch, tmp_path):
)
assert storage == os.path.join(str(root.resolve()), "storage")
assert rns == os.path.join(str(root.resolve()), ".reticulum")
+
+
+def _clear_log_dir_env(monkeypatch):
+ for var in ("MESHCHAT_LOG_DIR", "MESHCHAT_STORAGE_DIR", "MESHCHAT_DATA_DIR"):
+ monkeypatch.delenv(var, raising=False)
+
+
+def test_resolve_log_dir_prefers_explicit_log_dir_env(monkeypatch, tmp_path):
+ _clear_log_dir_env(monkeypatch)
+ log_dir = tmp_path / "custom-logs"
+ monkeypatch.setenv("MESHCHAT_LOG_DIR", str(log_dir))
+ monkeypatch.setenv("MESHCHAT_STORAGE_DIR", str(tmp_path / "storage"))
+ assert resolve_log_dir() == str(log_dir)
+
+
+def test_resolve_log_dir_uses_storage_dir_env(monkeypatch, tmp_path):
+ _clear_log_dir_env(monkeypatch)
+ storage_dir = tmp_path / "storage"
+ monkeypatch.setenv("MESHCHAT_STORAGE_DIR", str(storage_dir))
+ assert resolve_log_dir() == os.path.join(str(storage_dir), "logs")
+
+
+def test_resolve_log_dir_falls_back_to_data_dir_env_for_portable_mode(
+ monkeypatch,
+ tmp_path,
+):
+ """Keep logs off the home directory when only MESHCHAT_DATA_DIR is set.
+
+ A Tails-style persistent volume set via MESHCHAT_DATA_DIR should relocate
+ logs even when MESHCHAT_STORAGE_DIR is unset.
+ """
+ _clear_log_dir_env(monkeypatch)
+ persist_root = tmp_path / "tails"
+ monkeypatch.setenv("MESHCHAT_DATA_DIR", str(persist_root))
+ assert resolve_log_dir() == os.path.join(
+ str(persist_root.resolve()),
+ "storage",
+ "logs",
+ )
+
+
+def test_resolve_log_dir_storage_dir_env_wins_over_data_dir_env(monkeypatch, tmp_path):
+ _clear_log_dir_env(monkeypatch)
+ storage_dir = tmp_path / "explicit-storage"
+ data_dir = tmp_path / "persist"
+ monkeypatch.setenv("MESHCHAT_STORAGE_DIR", str(storage_dir))
+ monkeypatch.setenv("MESHCHAT_DATA_DIR", str(data_dir))
+ assert resolve_log_dir() == os.path.join(str(storage_dir), "logs")

diff --git a/tests/backend/test_database_auto_recover.py b/tests/backend/test_database_auto_recover.py
new file mode 100644
index 00000000..d779de43
--- /dev/null
+++ b/tests/backend/test_database_auto_recover.py
@@ -0,0 +1,159 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Tests for automatic database recovery backup selection."""
+
+from __future__ import annotations
+
+import os
+
+from meshchatx.src.backend.database import Database, PRE_MIGRATE_BACKUP_PREFIX
+from meshchatx.src.backend.database.auto_recover import (
+ infer_version_hint_from_backup_name,
+ pick_compatible_backup,
+ probe_backup_zip,
+ run_auto_database_recover,
+ schema_version_restorable,
+)
+from meshchatx.src.backend.database.schema import DatabaseSchema
+
+
+def test_schema_version_restorable_bounds():
+ latest = DatabaseSchema.LATEST_VERSION
+ assert schema_version_restorable(latest, latest)
+ assert schema_version_restorable(0, latest)
+ assert not schema_version_restorable(latest + 1, latest)
+ assert not schema_version_restorable(None, latest)
+
+
+def test_infer_version_hint_from_pre_migrate_name():
+ name = f"{PRE_MIGRATE_BACKUP_PREFIX}v52-to-v53-20260101-120000.zip"
+ assert infer_version_hint_from_backup_name(name) == 53
+ assert infer_version_hint_from_backup_name("backup-2026.zip") is None
+
+
+def test_pick_compatible_backup_skips_too_new_and_suspicious(tmp_path):
+ temp_dir = str(tmp_path)
+ db_path = os.path.join(temp_dir, "database.db")
+ db = Database(db_path)
+ db.initialize()
+ db.provider.execute(
+ "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+ ("marker", "pick-test"),
+ )
+ db.close_all()
+
+ backup_dir = os.path.join(temp_dir, "database-backups")
+ os.makedirs(backup_dir)
+ good = db.backup_database(temp_dir)
+ assert good["path"]
+
+ too_new_path = os.path.join(backup_dir, "backup-too-new.zip")
+ db2 = Database(db_path)
+ db2.initialize()
+ db2.provider.execute(
+ "UPDATE config SET value = ? WHERE key = ?",
+ (str(DatabaseSchema.LATEST_VERSION + 1), "database_version"),
+ )
+ db2.close_all()
+ too_new = Database(db_path)
+ too_new.backup_database(temp_dir, backup_path=too_new_path)
+ too_new.close_all()
+
+ suspicious_path = os.path.join(backup_dir, "backup-SUSPICIOUS-test.zip")
+ with open(suspicious_path, "wb") as handle:
+ handle.write(b"not-a-real-zip")
+
+ picked = pick_compatible_backup(temp_dir, DatabaseSchema.LATEST_VERSION)
+ assert picked is not None
+ assert picked["name"] == os.path.basename(good["path"])
+ assert picked["database_version"] == DatabaseSchema.LATEST_VERSION
+
+ probe = probe_backup_zip(good["path"])
+ assert probe["quick_check"] == "ok"
+ assert probe["version"] == DatabaseSchema.LATEST_VERSION
+
+
+def test_pick_compatible_backup_prefers_healthy_over_suspicious(tmp_path):
+ temp_dir = str(tmp_path)
+ db_path = os.path.join(temp_dir, "database.db")
+ db = Database(db_path)
+ db.initialize()
+ good = db.backup_database(temp_dir)
+ db.close_all()
+
+ backup_dir = os.path.join(temp_dir, "database-backups")
+ suspicious_path = os.path.join(backup_dir, "backup-SUSPICIOUS-only.zip")
+ with open(suspicious_path, "wb") as handle:
+ handle.write(b"PK\x03\x04")
+ # corrupt zip still listed but should not win over good backup
+
+ picked = pick_compatible_backup(temp_dir, DatabaseSchema.LATEST_VERSION)
+ assert picked is not None
+ assert picked["name"] == os.path.basename(good["path"])
+
+
+def test_run_auto_database_recover_restores_when_backup_exists(tmp_path):
+ temp_dir = str(tmp_path)
+ db_path = os.path.join(temp_dir, "database.db")
+ db = Database(db_path)
+ db.initialize()
+ backup_info = db.backup_database(temp_dir)
+ db.close_all()
+
+ restored_paths: list[str] = []
+
+ def restore_fn(path: str) -> dict:
+ restored_paths.append(path)
+ return {"path": path, "restored": True}
+
+ result = run_auto_database_recover(
+ temp_dir,
+ db_path,
+ DatabaseSchema.LATEST_VERSION,
+ restore_fn,
+ sqlite_recover_fn=lambda: {"actions": []},
+ )
+ assert result["strategy"] == "restore_backup"
+ assert restored_paths == [backup_info["path"]]
+ assert result["requires_relaunch"] is True
+
+
+def test_run_auto_database_recover_falls_back_to_sqlite(tmp_path):
+ temp_dir = str(tmp_path)
+ db_path = os.path.join(temp_dir, "database.db")
+ db = Database(db_path)
+ db.initialize()
+ db.close_all()
+
+ sqlite_called = False
+
+ def restore_fn(_path: str) -> dict:
+ raise AssertionError("restore should not run without backups")
+
+ def sqlite_fn() -> dict:
+ nonlocal sqlite_called
+ sqlite_called = True
+ return {"actions": [{"step": "wal_checkpoint"}]}
+
+ result = run_auto_database_recover(
+ temp_dir,
+ db_path,
+ DatabaseSchema.LATEST_VERSION,
+ restore_fn,
+ sqlite_recover_fn=sqlite_fn,
+ )
+ assert sqlite_called
+ assert result["strategy"] == "sqlite_recovery"
+ assert result["requires_relaunch"] is False
+
+
+def test_run_auto_database_recover_none_without_backups_or_db(tmp_path):
+ temp_dir = str(tmp_path)
+ result = run_auto_database_recover(
+ temp_dir,
+ None,
+ DatabaseSchema.LATEST_VERSION,
+ lambda _p: {},
+ sqlite_recover_fn=None,
+ )
+ assert result["strategy"] == "none"

diff --git a/tests/backend/test_module_ownership_contract.py b/tests/backend/test_module_ownership_contract.py
new file mode 100644
index 00000000..989d94f3
--- /dev/null
+++ b/tests/backend/test_module_ownership_contract.py
@@ -0,0 +1,105 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Contract test: docs/agents/module-ownership.md Backend table vs files on disk.
+
+Parses the Backend table directly from the doc so the checked-in fixture
+drifts loudly (via a failing diff) whenever a row is added, removed, or
+retyped, then asserts every manager module, HTTP route module, WS module,
+and frontend page path referenced by the table actually exists. Primary
+tests cells are prose-heavy, so only glob-like spans are checked, and only
+one matching test file per row is required since a row often lists several
+loosely related test names.
+"""
+
+import glob
+import os
+from pathlib import Path
+
+import pytest
+
+from tests.backend.module_ownership_contract_helpers import (
+ load_ownership_fixture,
+ parse_backend_ownership_table,
+ resolve_frontend_page_paths,
+ resolve_http_route_paths,
+ resolve_manager_module_paths,
+ resolve_ws_module_paths,
+ write_ownership_fixture,
+)
+
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+_DOC_PATH = _REPO_ROOT / "docs" / "agents" / "module-ownership.md"
+_FIXTURE = (
+ Path(__file__).resolve().parent / "fixtures" / "backend_module_ownership.json"
+)
+_TESTS_BACKEND_DIR = Path(__file__).resolve().parent
+
+
+def test_backend_ownership_table_matches_fixture():
+ parsed = parse_backend_ownership_table(_DOC_PATH)
+ if os.environ.get("UPDATE_BACKEND_MODULE_OWNERSHIP") == "1":
+ write_ownership_fixture(_FIXTURE, parsed)
+ pytest.skip(
+ "UPDATE_BACKEND_MODULE_OWNERSHIP=1: fixture updated; re-run without the env var",
+ )
+ expected = load_ownership_fixture(_FIXTURE)
+ assert parsed == expected, (
+ "Backend module ownership table drifted from the checked-in fixture. "
+ "If the docs/agents/module-ownership.md Backend table changed on purpose, run: "
+ "UPDATE_BACKEND_MODULE_OWNERSHIP=1 uv run pytest tests/backend/test_module_ownership_contract.py -k "
+ "backend_ownership_table_matches_fixture"
+ )
+
+
+def _assert_paths_exist(rows: list[dict], key: str, resolver, label: str) -> None:
+ for row in rows:
+ for rel_path, kind in resolver(row[key]):
+ full = _REPO_ROOT / rel_path
+ exists = full.is_dir() if kind == "dir" else full.is_file()
+ assert exists, f"{row['domain']}: {label} path missing on disk: {rel_path}"
+
+
+def test_manager_modules_exist_on_disk():
+ rows = load_ownership_fixture(_FIXTURE)
+ _assert_paths_exist(
+ rows, "manager_modules", resolve_manager_module_paths, "manager module"
+ )
+
+
+def test_http_route_modules_exist_on_disk():
+ rows = load_ownership_fixture(_FIXTURE)
+ _assert_paths_exist(
+ rows, "http_route_module", resolve_http_route_paths, "HTTP route module"
+ )
+
+
+def test_ws_modules_exist_on_disk():
+ rows = load_ownership_fixture(_FIXTURE)
+ _assert_paths_exist(rows, "ws_module", resolve_ws_module_paths, "WS module")
+
+
+def test_frontend_pages_exist_on_disk():
+ rows = load_ownership_fixture(_FIXTURE)
+ _assert_paths_exist(
+ rows, "frontend_page", resolve_frontend_page_paths, "frontend page"
+ )
+
+
+def _looks_like_test_glob(span: str) -> bool:
+ return span.startswith("test_")
+
+
+def test_primary_tests_glob_patterns_match_at_least_one_file():
+ rows = load_ownership_fixture(_FIXTURE)
+ for row in rows:
+ globs = [span for span in row["primary_tests"] if _looks_like_test_glob(span)]
+ if not globs:
+ continue
+ matched = []
+ for pattern in globs:
+ matched.extend(
+ glob.glob(str(_TESTS_BACKEND_DIR / "**" / pattern), recursive=True)
+ )
+ assert matched, (
+ f"{row['domain']}: none of {globs} matched a file under tests/backend/"
+ )

diff --git a/tests/backend/test_page_node.py b/tests/backend/test_page_node.py
index 7ee60be9..1c4519f8 100644
--- a/tests/backend/test_page_node.py
+++ b/tests/backend/test_page_node.py
@@ -102,7 +102,7 @@ class TestPageNodeSetup:
rns_mock, _, _ = mock_rns
node = _make_node(node_dir, mock_rns)
node.setup()
- rns_mock.Identity.remember.assert_called_once()
+ rns_mock.Identity.remember.assert_called()
class TestPageNodeTeardown:
@@ -137,6 +137,7 @@ class TestPageNodeAnnounce:
node = _make_node(node_dir, mock_rns)
node.setup()
_, _, mock_dest = mock_rns
+ mock_dest.announce.reset_mock()
node.announce()
mock_dest.announce.assert_called_once()
@@ -154,6 +155,228 @@ class TestPageNodeAnnounce:
call_kwargs = mock_dest.announce.call_args
assert call_kwargs[1]["app_data"] == b"Test Node"
+ def test_setup_announces_immediately_when_enabled(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ _, _, mock_dest = mock_rns
+ node.setup()
+ mock_dest.announce.assert_called_once()
+
+ def test_setup_skips_announce_when_disabled(self, node_dir, mock_rns):
+ from meshchatx.src.backend.page_node import PageNode
+
+ _, mock_identity, _ = mock_rns
+ node = PageNode(
+ node_id="test-node-disabled",
+ name="Disabled Node",
+ base_dir=node_dir,
+ identity=mock_identity,
+ announce_enabled=False,
+ )
+ _, _, mock_dest = mock_rns
+ node.setup()
+ mock_dest.announce.assert_not_called()
+
+ def test_announce_records_last_announced_at(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ assert node.last_announced_at is None
+ node.setup()
+ assert node.last_announced_at is not None
+
+ def test_announce_does_not_update_last_announced_at_when_not_running(
+ self, node_dir, mock_rns
+ ):
+ node = _make_node(node_dir, mock_rns)
+ node.announce()
+ assert node.last_announced_at is None
+
+ def test_announce_invokes_on_announce_callback(self, node_dir, mock_rns):
+ from meshchatx.src.backend.page_node import PageNode
+
+ _, mock_identity, _ = mock_rns
+ calls = []
+ node = PageNode(
+ node_id="test-node-cb",
+ name="Callback Node",
+ base_dir=node_dir,
+ identity=mock_identity,
+ on_announce=calls.append,
+ )
+ node.setup()
+ assert calls == [node]
+
+ def test_announce_callback_exceptions_are_suppressed(self, node_dir, mock_rns):
+ from meshchatx.src.backend.page_node import PageNode
+
+ _, mock_identity, _ = mock_rns
+
+ def boom(_node):
+ raise RuntimeError("boom")
+
+ node = PageNode(
+ node_id="test-node-cb-boom",
+ name="Boom Node",
+ base_dir=node_dir,
+ identity=mock_identity,
+ on_announce=boom,
+ )
+ node.setup() # must not raise
+ assert node.running is True
+
+ def test_teardown_cancels_announce_timer(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node._announce_timer is not None
+ node.teardown()
+ assert node._announce_timer is None
+
+
+class TestPageNodeAnnounceInterval:
+ def test_normalize_defaults_when_none(self):
+ from meshchatx.src.backend.page_node import (
+ DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+ normalize_announce_interval_seconds,
+ )
+
+ assert (
+ normalize_announce_interval_seconds(None)
+ == DEFAULT_ANNOUNCE_INTERVAL_SECONDS
+ )
+
+ def test_normalize_zero_or_negative_disables(self):
+ from meshchatx.src.backend.page_node import normalize_announce_interval_seconds
+
+ assert normalize_announce_interval_seconds(0) == 0
+ assert normalize_announce_interval_seconds(-30) == 0
+
+ def test_normalize_clamps_to_min_and_max(self):
+ from meshchatx.src.backend.page_node import (
+ MAX_ANNOUNCE_INTERVAL_SECONDS,
+ MIN_ANNOUNCE_INTERVAL_SECONDS,
+ normalize_announce_interval_seconds,
+ )
+
+ assert normalize_announce_interval_seconds(1) == MIN_ANNOUNCE_INTERVAL_SECONDS
+ assert (
+ normalize_announce_interval_seconds(999999) == MAX_ANNOUNCE_INTERVAL_SECONDS
+ )
+
+ def test_normalize_invalid_type_returns_default(self):
+ from meshchatx.src.backend.page_node import (
+ DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+ normalize_announce_interval_seconds,
+ )
+
+ assert (
+ normalize_announce_interval_seconds("not-a-number")
+ == DEFAULT_ANNOUNCE_INTERVAL_SECONDS
+ )
+
+ def test_node_defaults_to_default_interval(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ from meshchatx.src.backend.page_node import DEFAULT_ANNOUNCE_INTERVAL_SECONDS
+
+ assert node.announce_interval_seconds == DEFAULT_ANNOUNCE_INTERVAL_SECONDS
+ assert node.announce_enabled is True
+
+ def test_node_clamps_custom_interval_on_construction(self, node_dir, mock_rns):
+ from meshchatx.src.backend.page_node import PageNode
+
+ _, mock_identity, _ = mock_rns
+ node = PageNode(
+ node_id="test-node-clamp",
+ name="Clamp Node",
+ base_dir=node_dir,
+ identity=mock_identity,
+ announce_interval_seconds=5,
+ )
+ assert node.announce_interval_seconds == 60
+
+ def test_setup_starts_announce_timer_when_enabled(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node._announce_timer is not None
+
+ def test_setup_skips_timer_when_disabled(self, node_dir, mock_rns):
+ from meshchatx.src.backend.page_node import PageNode
+
+ _, mock_identity, _ = mock_rns
+ node = PageNode(
+ node_id="test-node-no-timer",
+ name="No Timer Node",
+ base_dir=node_dir,
+ identity=mock_identity,
+ announce_enabled=False,
+ )
+ node.setup()
+ assert node._announce_timer is None
+
+ def test_setup_skips_timer_when_interval_zero(self, node_dir, mock_rns):
+ from meshchatx.src.backend.page_node import PageNode
+
+ _, mock_identity, _ = mock_rns
+ node = PageNode(
+ node_id="test-node-zero-interval",
+ name="Zero Interval Node",
+ base_dir=node_dir,
+ identity=mock_identity,
+ announce_interval_seconds=0,
+ )
+ node.setup()
+ assert node._announce_timer is None
+
+ def test_announce_timer_fire_reannounces_and_reschedules(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ _, _, mock_dest = mock_rns
+ mock_dest.announce.reset_mock()
+ node._announce_timer_fire()
+ mock_dest.announce.assert_called_once()
+ assert node._announce_timer is not None
+
+ def test_announce_timer_fire_noop_when_disabled_meanwhile(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ _, _, mock_dest = mock_rns
+ node.announce_enabled = False
+ mock_dest.announce.reset_mock()
+ node._announce_timer_fire()
+ mock_dest.announce.assert_not_called()
+ assert node._announce_timer is None
+
+ def test_set_announce_settings_updates_enabled_and_interval(
+ self, node_dir, mock_rns
+ ):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.set_announce_settings(announce_enabled=True, announce_interval_seconds=120)
+ assert node.announce_enabled is True
+ assert node.announce_interval_seconds == 120
+ assert node._announce_timer is not None
+
+ def test_set_announce_settings_disable_cancels_timer(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.set_announce_settings(announce_enabled=False)
+ assert node.announce_enabled is False
+ assert node._announce_timer is None
+
+ def test_set_announce_settings_interval_zero_cancels_timer(
+ self, node_dir, mock_rns
+ ):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.set_announce_settings(announce_interval_seconds=0)
+ assert node.announce_interval_seconds == 0
+ assert node._announce_timer is None
+
+ def test_set_announce_settings_partial_update_keeps_other_field(
+ self, node_dir, mock_rns
+ ):
+ node = _make_node(node_dir, mock_rns)
+ node.set_announce_settings(announce_interval_seconds=300)
+ assert node.announce_enabled is True
+ assert node.announce_interval_seconds == 300
+
class TestPageNodePages:
def test_add_page_writes_file(self, node_dir, mock_rns):
@@ -369,6 +592,32 @@ class TestPageNodeConfig:
assert PageNode.load_config(node_dir) is None
+ def test_save_config_persists_announce_settings(self, node_dir, mock_rns):
+ from meshchatx.src.backend.page_node import PageNode
+
+ node = _make_node(node_dir, mock_rns)
+ node.set_announce_settings(
+ announce_enabled=False, announce_interval_seconds=120
+ )
+ node.save_config()
+
+ config = PageNode.load_config(node_dir)
+ assert config["announce_enabled"] is False
+ assert config["announce_interval_seconds"] == 120
+
+ def test_save_config_defaults_announce_settings(self, node_dir, mock_rns):
+ from meshchatx.src.backend.page_node import (
+ DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+ PageNode,
+ )
+
+ node = _make_node(node_dir, mock_rns)
+ node.save_config()
+
+ config = PageNode.load_config(node_dir)
+ assert config["announce_enabled"] is True
+ assert config["announce_interval_seconds"] == DEFAULT_ANNOUNCE_INTERVAL_SECONDS
+
class TestPageNodeStatus:
def test_get_status(self, node_dir, mock_rns):
@@ -385,6 +634,19 @@ class TestPageNodeStatus:
assert status["unique_connections"] == 0
assert status["uptime_seconds"] >= 0
+ def test_get_status_includes_announce_fields(self, node_dir, mock_rns):
+ from meshchatx.src.backend.page_node import DEFAULT_ANNOUNCE_INTERVAL_SECONDS
+
+ node = _make_node(node_dir, mock_rns)
+ status = node.get_status()
+ assert status["announce_enabled"] is True
+ assert status["announce_interval_seconds"] == DEFAULT_ANNOUNCE_INTERVAL_SECONDS
+ assert status["last_announced_at"] is None
+
+ node.setup()
+ status = node.get_status()
+ assert status["last_announced_at"] is not None
+
def test_get_destination_hash_when_not_running(self, node_dir, mock_rns):
node = _make_node(node_dir, mock_rns)
assert node.get_destination_hash() is None

diff --git a/tests/backend/test_page_node_manager.py b/tests/backend/test_page_node_manager.py
index 9d49744d..f4397fc1 100644
--- a/tests/backend/test_page_node_manager.py
+++ b/tests/backend/test_page_node_manager.py
@@ -167,6 +167,78 @@ class TestPageNodeManagerAnnounce:
assert mock_dest.announce.call_count >= 2
+class TestPageNodeManagerAnnounceSettings:
+ def test_create_node_with_custom_announce_settings(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node(
+ "Custom", announce_enabled=False, announce_interval_seconds=120
+ )
+ assert node.announce_enabled is False
+ assert node.announce_interval_seconds == 120
+
+ def test_create_node_defaults_announce_enabled(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("Defaults")
+ assert node.announce_enabled is True
+
+ def test_set_announce_settings_updates_node(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("Settable")
+ mgr.set_announce_settings(
+ node.node_id, announce_enabled=False, announce_interval_seconds=300
+ )
+ assert node.announce_enabled is False
+ assert node.announce_interval_seconds == 300
+
+ def test_set_announce_settings_persists_to_disk(self, storage_dir, mock_rns):
+ from meshchatx.src.backend.page_node import PageNode
+
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("Persisted", node_id="persist-announce")
+ mgr.set_announce_settings(node.node_id, announce_enabled=False)
+
+ config = PageNode.load_config(node.base_dir)
+ assert config["announce_enabled"] is False
+
+ def test_set_announce_settings_nonexistent_raises(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ with pytest.raises(KeyError):
+ mgr.set_announce_settings("nope", announce_enabled=False)
+
+ def test_load_nodes_restores_announce_settings(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("Reloaded", node_id="reload-1")
+ mgr.set_announce_settings(
+ node.node_id, announce_enabled=False, announce_interval_seconds=200
+ )
+
+ mgr2 = _make_manager(storage_dir)
+ mgr2.load_nodes()
+ reloaded = mgr2.nodes["reload-1"]
+ assert reloaded.announce_enabled is False
+ assert reloaded.announce_interval_seconds == 200
+
+ def test_on_announce_callback_wired_through_manager(self, storage_dir, mock_rns):
+ calls = []
+ mgr = _make_manager(storage_dir)
+ mgr.on_announce = calls.append
+ node = mgr.create_node("Callback")
+ mgr.start_node(node.node_id)
+ assert calls == [node]
+
+ def test_on_announce_callback_wired_for_loaded_nodes(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("ToReload", node_id="reload-cb")
+ node.save_config()
+
+ calls = []
+ mgr2 = _make_manager(storage_dir)
+ mgr2.on_announce = calls.append
+ mgr2.load_nodes()
+ mgr2.start_node("reload-cb")
+ assert calls == [mgr2.nodes["reload-cb"]]
+
+
class TestPageNodeManagerRename:
def test_rename_node(self, storage_dir, mock_rns):
mgr = _make_manager(storage_dir)

diff --git a/tests/backend/test_schema_migration_matrix.py b/tests/backend/test_schema_migration_matrix.py
index 3bcc26a5..c7295629 100644
--- a/tests/backend/test_schema_migration_matrix.py
+++ b/tests/backend/test_schema_migration_matrix.py
@@ -4,10 +4,9 @@
from __future__ import annotations
-import json
+import os
import shutil
import tempfile
-import os
from pathlib import Path
from unittest.mock import patch
@@ -22,6 +21,11 @@ from meshchatx.src.backend.database import (
)
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.schema import DatabaseSchema
+from tests.backend.schema_versions_contract_helpers import (
+ derive_schema_versions_manifest,
+ load_schema_versions_manifest,
+ write_schema_versions_manifest,
+)
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "schema_versions"
MANIFEST = FIXTURE_DIR / "manifest.json"
@@ -46,10 +50,20 @@ def _fixture_path(version: int) -> Path:
def test_manifest_matches_latest_version():
+ live = derive_schema_versions_manifest(DatabaseSchema.LATEST_VERSION)
+ if os.environ.get("UPDATE_SCHEMA_VERSIONS_MANIFEST") == "1":
+ write_schema_versions_manifest(MANIFEST, live)
+ pytest.skip(
+ "UPDATE_SCHEMA_VERSIONS_MANIFEST=1: fixture updated; re-run without the env var",
+ )
if not MANIFEST.is_file():
pytest.skip("schema fixture manifest missing")
- data = json.loads(MANIFEST.read_text(encoding="utf-8"))
- assert int(data["latest_version"]) == DatabaseSchema.LATEST_VERSION
+ expected = load_schema_versions_manifest(MANIFEST)
+ assert live == expected, (
+ "Schema versions manifest drifted. Run: "
+ "UPDATE_SCHEMA_VERSIONS_MANIFEST=1 uv run pytest "
+ "tests/backend/test_schema_migration_matrix.py -k manifest_matches_latest_version"
+ )
@pytest.mark.parametrize(

diff --git a/tests/electron/mainHelpers.test.js b/tests/electron/mainHelpers.test.js
index aaa7f46a..f2735573 100644
--- a/tests/electron/mainHelpers.test.js
+++ b/tests/electron/mainHelpers.test.js
@@ -4,10 +4,13 @@ import { createRequire } from "module";
const require = createRequire(import.meta.url);
const {
getUserProvidedArguments,
+ parseArgvFlag,
+ resolvePortableStorageRoots,
formatRenderProcessGoneDetails,
isLocalBackendUrl,
shouldOpenInElectronWindow,
} = require("../../electron/mainHelpers.js");
+const path = require("node:path");
describe("electron/mainHelpers", () => {
it("getUserProvidedArguments filters ignored flags and skips argv[0]", () => {
@@ -51,4 +54,107 @@ describe("electron/mainHelpers", () => {
expect(shouldAllowInWindowNavigation("https://example.com/")).toBe(false);
expect(shouldAllowInWindowNavigation("file:///etc/passwd")).toBe(false);
});
+
+ it("parseArgvFlag reads a value following the flag", () => {
+ expect(parseArgvFlag(["--storage-dir", "/mnt/persist"], "--storage-dir")).toBe("/mnt/persist");
+ });
+
+ it("parseArgvFlag returns null when the flag is missing or has no value", () => {
+ expect(parseArgvFlag(["--headless"], "--storage-dir")).toBeNull();
+ expect(parseArgvFlag(["--storage-dir"], "--storage-dir")).toBeNull();
+ expect(parseArgvFlag(["--storage-dir", "--headless"], "--storage-dir")).toBeNull();
+ });
+});
+
+describe("electron/mainHelpers resolvePortableStorageRoots (portable mode)", () => {
+ const homeDir = path.join("home", "user");
+
+ function resolve(overrides = {}) {
+ return resolvePortableStorageRoots({
+ argv: ["/app/electron"],
+ env: {},
+ homeDir,
+ isWindows: false,
+ portableExecutableDir: null,
+ ...overrides,
+ });
+ }
+
+ it("defaults to the home directory when nothing is configured", () => {
+ expect(resolve()).toEqual({
+ storageDir: path.join(homeDir, ".reticulum-meshchatx"),
+ reticulumConfigDir: path.join(homeDir, ".reticulum"),
+ });
+ });
+
+ it("derives storage and reticulum roots from --data-dir", () => {
+ const roots = resolve({ argv: ["/app/electron", "--data-dir", "/mnt/tails/persist"] });
+ expect(roots).toEqual({
+ storageDir: path.resolve("/mnt/tails/persist", "storage"),
+ reticulumConfigDir: path.resolve("/mnt/tails/persist", ".reticulum"),
+ });
+ });
+
+ it("derives storage and reticulum roots from MESHCHAT_DATA_DIR", () => {
+ const roots = resolve({ env: { MESHCHAT_DATA_DIR: "/mnt/tails/persist" } });
+ expect(roots).toEqual({
+ storageDir: path.resolve("/mnt/tails/persist", "storage"),
+ reticulumConfigDir: path.resolve("/mnt/tails/persist", ".reticulum"),
+ });
+ });
+
+ it("explicit --storage-dir and --reticulum-config-dir win over --data-dir", () => {
+ const roots = resolve({
+ argv: ["/app/electron", "--data-dir", "/mnt/tails/persist", "--storage-dir", "/mnt/tails/custom-storage"],
+ });
+ expect(roots.storageDir).toBe("/mnt/tails/custom-storage");
+ expect(roots.reticulumConfigDir).toBe(path.resolve("/mnt/tails/persist", ".reticulum"));
+ });
+
+ it("explicit MESHCHAT_STORAGE_DIR / MESHCHAT_RETICULUM_CONFIG_DIR env vars win over MESHCHAT_DATA_DIR", () => {
+ const roots = resolve({
+ env: {
+ MESHCHAT_DATA_DIR: "/mnt/tails/persist",
+ MESHCHAT_STORAGE_DIR: "/mnt/tails/custom-storage",
+ },
+ });
+ expect(roots.storageDir).toBe("/mnt/tails/custom-storage");
+ expect(roots.reticulumConfigDir).toBe(path.resolve("/mnt/tails/persist", ".reticulum"));
+ });
+
+ it("argv flags win over env vars for the same setting", () => {
+ const roots = resolve({
+ argv: ["/app/electron", "--data-dir", "/mnt/argv-persist"],
+ env: { MESHCHAT_DATA_DIR: "/mnt/env-persist" },
+ });
+ expect(roots.storageDir).toBe(path.resolve("/mnt/argv-persist", "storage"));
+ });
+
+ it("falls back to the Windows portable executable directory when set", () => {
+ const roots = resolve({
+ isWindows: true,
+ portableExecutableDir: "E:\\Portable",
+ });
+ expect(roots).toEqual({
+ storageDir: path.join("E:\\Portable", ".reticulum-meshchatx"),
+ reticulumConfigDir: path.join("E:\\Portable", ".reticulum"),
+ });
+ });
+
+ it("ignores the Windows portable executable directory on non-Windows platforms", () => {
+ const roots = resolve({
+ isWindows: false,
+ portableExecutableDir: "/mnt/portable",
+ });
+ expect(roots.storageDir).toBe(path.join(homeDir, ".reticulum-meshchatx"));
+ });
+
+ it("--data-dir wins over the Windows portable executable directory", () => {
+ const roots = resolve({
+ argv: ["/app/electron", "--data-dir", "/mnt/tails/persist"],
+ isWindows: true,
+ portableExecutableDir: "E:\\Portable",
+ });
+ expect(roots.storageDir).toBe(path.resolve("/mnt/tails/persist", "storage"));
+ });
});

diff --git a/tests/frontend/AboutPage.test.js b/tests/frontend/AboutPage.test.js
index cc649b63..ebfb1c45 100644
--- a/tests/frontend/AboutPage.test.js
+++ b/tests/frontend/AboutPage.test.js
@@ -344,6 +344,35 @@ describe("AboutPage.vue", () => {
expect(wrapper.vm.databaseActionError).toBe("about.recovery_failed");
});
+ it("runs auto recover and schedules relaunch when a compatible backup is restored", async () => {
+ vi.spyOn(DialogUtils, "confirm").mockResolvedValue(true);
+ axiosMock.get.mockResolvedValue({
+ data: { app_info: {}, config: {}, database: {} },
+ });
+ axiosMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/database/auto-recover") {
+ return Promise.resolve({
+ data: {
+ strategy: "restore_backup",
+ message: "Restored database from backup-1.zip",
+ requires_relaunch: true,
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ const wrapper = mountAboutPage();
+ await wrapper.vm.$nextTick();
+ const scheduleSpy = vi.spyOn(wrapper.vm, "scheduleRestoreRelaunch");
+
+ await wrapper.vm.runAutoRecover();
+
+ expect(DialogUtils.confirm).toHaveBeenCalledWith("about.auto_recover_confirm");
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/database/auto-recover", { relaunch: true });
+ expect(ToastUtils.success).toHaveBeenCalledWith("Restored database from backup-1.zip");
+ expect(scheduleSpy).toHaveBeenCalled();
+ });
+
it("displays Free Space from database health", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/app/info") return Promise.resolve({ data: { app_info: { version: "1.0.0" } } });

diff --git a/tests/frontend/PageNodesPage.test.js b/tests/frontend/PageNodesPage.test.js
new file mode 100644
index 00000000..e8a363d5
--- /dev/null
+++ b/tests/frontend/PageNodesPage.test.js
@@ -0,0 +1,163 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import PageNodesPage from "@/components/page-nodes/PageNodesPage.vue";
+import { mountToolsPageGlobals } from "./testI18n.js";
+
+const NODE_ID = "node-1";
+
+function makeNode(overrides = {}) {
+ return {
+ node_id: NODE_ID,
+ name: "My Server",
+ running: true,
+ destination_hash: "aabbccddeeff00112233445566778899",
+ identity_hash: "1122334455667788",
+ active_links: 0,
+ unique_connections: 0,
+ uptime_seconds: 60,
+ pages: ["index.mu"],
+ files: [],
+ stats: { pages_served: 0, files_served: 0, links_established: 0 },
+ announce_enabled: true,
+ announce_interval_seconds: 900,
+ last_announced_at: null,
+ ...overrides,
+ };
+}
+
+describe("PageNodesPage.vue", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn().mockResolvedValue({ data: [makeNode()] }),
+ post: vi.fn().mockResolvedValue({ data: {} }),
+ patch: vi.fn().mockResolvedValue({ data: makeNode() }),
+ delete: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ window.api = axiosMock;
+ });
+
+ afterEach(() => {
+ delete window.api;
+ });
+
+ const mountPage = () => mount(PageNodesPage, { global: mountToolsPageGlobals() });
+
+ it("loads and renders mesh servers", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.nodes.length).toBe(1));
+ expect(wrapper.text()).toContain("My Server");
+ });
+
+ it("shows an auto-announce off badge when announce is disabled", async () => {
+ axiosMock.get.mockResolvedValue({ data: [makeNode({ announce_enabled: false })] });
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.nodes.length).toBe(1));
+ expect(wrapper.text()).toContain("Auto-announce off");
+ });
+
+ it("does not show the auto-announce off badge when announce is enabled", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.nodes.length).toBe(1));
+ expect(wrapper.text()).not.toContain("Auto-announce off");
+ });
+
+ it("shows never announced when a node has not announced yet", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.nodes.length).toBe(1));
+ expect(wrapper.text()).toContain("Never announced");
+ });
+
+ it("shows last announced time when a node has announced", async () => {
+ const lastAnnouncedAt = Date.now() / 1000 - 120;
+ axiosMock.get.mockResolvedValue({ data: [makeNode({ last_announced_at: lastAnnouncedAt })] });
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.nodes.length).toBe(1));
+ expect(wrapper.text()).toContain("Last announced");
+ });
+
+ it("initializes the announce settings form when selecting a node", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.nodes.length).toBe(1));
+ wrapper.vm.selectNode(wrapper.vm.nodes[0]);
+ expect(wrapper.vm.announceSettingsForm).toEqual({
+ announce_enabled: true,
+ announce_interval_seconds: 900,
+ });
+ });
+
+ it("defaults the announce settings form for a disabled node", async () => {
+ axiosMock.get.mockResolvedValue({
+ data: [makeNode({ announce_enabled: false, announce_interval_seconds: 300 })],
+ });
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.nodes.length).toBe(1));
+ wrapper.vm.selectNode(wrapper.vm.nodes[0]);
+ expect(wrapper.vm.announceSettingsForm).toEqual({
+ announce_enabled: false,
+ announce_interval_seconds: 300,
+ });
+ });
+
+ it("converts the announce interval to and from minutes", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.nodes.length).toBe(1));
+ wrapper.vm.selectNode(wrapper.vm.nodes[0]);
+ expect(wrapper.vm.announceIntervalMinutes).toBe(15);
+
+ wrapper.vm.announceIntervalMinutes = 30;
+ expect(wrapper.vm.announceSettingsForm.announce_interval_seconds).toBe(1800);
+ });
+
+ it("clamps the announce interval minutes to the supported range", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.nodes.length).toBe(1));
+ wrapper.vm.selectNode(wrapper.vm.nodes[0]);
+
+ wrapper.vm.announceIntervalMinutes = 0;
+ expect(wrapper.vm.announceSettingsForm.announce_interval_seconds).toBe(60);
+
+ wrapper.vm.announceIntervalMinutes = 999999;
+ expect(wrapper.vm.announceSettingsForm.announce_interval_seconds).toBe(1440 * 60);
+ });
+
+ it("saves announce settings via the PATCH API", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.nodes.length).toBe(1));
+ wrapper.vm.selectNode(wrapper.vm.nodes[0]);
+
+ wrapper.vm.announceSettingsForm = {
+ announce_enabled: false,
+ announce_interval_seconds: 300,
+ };
+ await wrapper.vm.saveAnnounceSettings();
+
+ expect(axiosMock.patch).toHaveBeenCalledWith(`/api/v1/page-nodes/${NODE_ID}/announce-settings`, {
+ announce_enabled: false,
+ announce_interval_seconds: 300,
+ });
+ });
+
+ it("shows an error toast when saving announce settings fails", async () => {
+ axiosMock.patch.mockRejectedValueOnce(new Error("boom"));
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.nodes.length).toBe(1));
+ wrapper.vm.selectNode(wrapper.vm.nodes[0]);
+
+ await wrapper.vm.saveAnnounceSettings();
+ expect(wrapper.vm.statusMessage).toBe("Failed to save announce settings");
+ expect(wrapper.vm.statusSuccess).toBe(false);
+ });
+
+ it("creates a node without touching announce settings by default", async () => {
+ axiosMock.post.mockResolvedValueOnce({ data: makeNode({ name: "Fresh Node" }) });
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.nodes.length).toBe(1));
+
+ wrapper.vm.createNodeName = "Fresh Node";
+ await wrapper.vm.createNode();
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/page-nodes", { name: "Fresh Node" });
+ });
+});

diff --git a/tests/frontend/networkRecovery.test.js b/tests/frontend/networkRecovery.test.js
index a0dff154..0ca70e50 100644
--- a/tests/frontend/networkRecovery.test.js
+++ b/tests/frontend/networkRecovery.test.js
@@ -3,7 +3,9 @@
import { describe, expect, it } from "vitest";
import {
+ isDatabaseRecoveryError,
isLikelyInterfaceRecoveryError,
+ recoveryLocationForNetworkError,
recoveryRouteForNetworkError,
} from "../../meshchatx/src/frontend/js/networkRecovery.js";
@@ -19,4 +21,15 @@ describe("networkRecovery", () => {
expect(recoveryRouteForNetworkError("AutoInterface bind error")).toBe("interfaces");
expect(recoveryRouteForNetworkError("I2P interface unavailable")).toBe("interfaces");
});
+
+ it("detects database recovery errors and routes to About backups", () => {
+ const msg =
+ "Database version 55 is newer than this MeshChatX build supports (54). Restore a backup or upgrade the application.";
+ expect(isDatabaseRecoveryError(msg)).toBe(true);
+ expect(isLikelyInterfaceRecoveryError(msg)).toBe(false);
+ expect(recoveryLocationForNetworkError(msg)).toEqual({
+ name: "about",
+ hash: "#about-database-backups",
+ });
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────